+
- {{ t('ai.pickButton') }}
- {{ t('ai.newCategory.button') }}
+ {{ t('ai.pickButton') }}
+ {{ t('ai.newCategory.button') }}
@@ -1756,8 +2290,19 @@ onMounted(() => {
-
+
+
+ {{ t('ai.pickButton') }}
+ {{ t('ai.newChannel.button') }}
+
+
+
+ {{ t('ai.newChannel.hint') }}
+ {{ pendingChannel.name }}
+ {{ t('ai.newChannel.save') }}
+ {{ t('common.cancel') }}
+
@@ -1765,7 +2310,7 @@ onMounted(() => {
-
+
{
:placeholder="t('transaction.tagPlaceholder')"
style="flex: 1;"
/>
- {{ t('ai.pickButton') }}
- {{ t('ai.newTag.button') }}
+ {{ t('ai.pickButton') }}
+ {{ t('ai.newTag.button') }}
@@ -1802,6 +2347,23 @@ onMounted(() => {
+
+
+
+
+
+
![source image]()
+
+ {{ t('transaction.originalImage') }}
+
+ {{ t('transaction.loadOriginal') }}
+
+
+
+
+
+
+
@@ -1871,19 +2433,96 @@ onMounted(() => {
-
-
+
+
+
+
+
+ {{ t('ai.record.hint') }}
+
+
+ {{ t('common.cancel') }}
+ {{ t('ai.record.parse') }}
+
+
+
+
+
+ {{ t('ai.record.imageHint') }}
+
+
+
+ {{ t('ai.record.upload') }}
+ {{ t('ai.record.uploadTip') }}
+ {{ t('ai.record.pasteHint') }}
+
+
+
+
+
+
+
+ {{ t('ai.record.progressTitle') }}
+
+
+
+
+ {{ item.time }}
+ {{ item.text }}
+
+
+
+
+ {{ t('common.cancel') }}
+
+ {{ t('ai.record.parseImage') }}
+
+
+
+
+
+
+
+
+
- {{ t('ai.record.hint') }}
-
+ {{ aiRecordSummary || t('ai.record.resultHint') }}
+
+
- {{ t('common.cancel') }}
- {{ t('ai.record.parse') }}
+ {{ t('common.cancel') }}
+
+ {{ t('ai.record.importSelected', { count: checkedAiRecordKeys.length }) }}
+
@@ -1962,4 +2601,69 @@ onMounted(() => {
:deep(.excluded-row .remark-text) {
text-decoration: line-through;
}
+/* 图片识别进度:浅色卡片 + 步骤列表 */
+.image-view-item {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.image-view-actions {
+ display: flex;
+ justify-content: flex-end;
+}
+.ai-progress {
+ background: #f7f9fc;
+ border: 1px solid #eef1f6;
+ border-radius: 8px;
+ padding: 10px 12px;
+}
+.ai-progress-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #666;
+}
+.ai-progress-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-height: 180px;
+ overflow-y: auto;
+}
+.ai-progress-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: #555;
+}
+.ai-progress-icon {
+ margin-top: 2px;
+ flex-shrink: 0;
+ color: #18a058;
+}
+/* 最新一条处理中时用蓝色旋转图标 */
+.ai-progress-item.is-running .ai-progress-icon {
+ color: #2080f0;
+ animation: ai-progress-spin 1s linear infinite;
+}
+.ai-progress-time {
+ flex-shrink: 0;
+ margin-top: 1px;
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+ font-size: 12px;
+ color: #a0a4ab;
+}
+.ai-progress-text {
+ word-break: break-all;
+}
+@keyframes ai-progress-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/client/DeepSeekClient.java b/src/main/java/cn/rosercode/finance/client/DeepSeekClient.java
index 5da22b8..64d31df 100644
--- a/src/main/java/cn/rosercode/finance/client/DeepSeekClient.java
+++ b/src/main/java/cn/rosercode/finance/client/DeepSeekClient.java
@@ -1,6 +1,8 @@
package cn.rosercode.finance.client;
import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -9,6 +11,9 @@ import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.errors.OpenAIException;
import com.openai.models.ResponseFormatJsonObject;
import com.openai.models.chat.completions.ChatCompletion;
+import com.openai.models.chat.completions.ChatCompletionContentPart;
+import com.openai.models.chat.completions.ChatCompletionContentPartImage;
+import com.openai.models.chat.completions.ChatCompletionContentPartText;
import com.openai.models.chat.completions.ChatCompletionCreateParams;
import com.openai.models.completions.CompletionUsage;
import lombok.extern.slf4j.Slf4j;
@@ -29,8 +34,11 @@ import cn.rosercode.finance.common.BusinessException;
@Slf4j
public class DeepSeekClient {
- /** 单次请求的读取超时时间 */
- private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60);
+ /** 文本类请求的读取超时时间 */
+ private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(120);
+
+ /** 视觉请求的读取超时时间(图片识别耗时较长,单独放宽) */
+ private static final Duration IMAGE_REQUEST_TIMEOUT = Duration.ofSeconds(240);
/** 缓存的客户端上限,超出后清空重建,避免按用户无限增长 */
private static final int MAX_CACHED_CLIENTS = 32;
@@ -52,8 +60,7 @@ public class DeepSeekClient {
* @return 模型输出内容与 token 用量
*/
public DeepSeekResponse chat(String baseUrl, String apiKey, String model, String systemPrompt, String userPrompt) {
- long start = System.currentTimeMillis();
- OpenAIClient client = resolveClient(baseUrl, apiKey);
+ OpenAIClient client = resolveClient(baseUrl, apiKey, REQUEST_TIMEOUT);
// 强制返回 JSON 对象,与各 AI 服务的结构化输出约定一致
ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
@@ -64,12 +71,65 @@ public class DeepSeekClient {
.addUserMessage(userPrompt)
.build();
+ return execute(client, model, params);
+ }
+
+ /**
+ * 调用支持视觉的 Chat Completions 接口,携带文本与一张或多张图片。
+ *
+ * 图片以 base64 data URL 形式放入 user 消息的内容块中;DeepSeek 视觉接口
+ * 仅允许图片出现在 user 消息里,因此图片统一随用户消息发送。
+ *
+ * @param baseUrl API 基础地址
+ * @param apiKey API Key
+ * @param model 视觉模型名称
+ * @param systemPrompt 系统提示词
+ * @param userText 用户提示词(可为空)
+ * @param imageDataUrls 图片的 base64 data URL 列表
+ * @return 模型输出内容与 token 用量
+ */
+ public DeepSeekResponse chatWithImages(String baseUrl, String apiKey, String model, String systemPrompt,
+ String userText, List imageDataUrls) {
+ OpenAIClient client = resolveClient(baseUrl, apiKey, IMAGE_REQUEST_TIMEOUT);
+
+ List parts = new ArrayList<>();
+ if (userText != null && !userText.isBlank()) {
+ parts.add(ChatCompletionContentPart.ofText(
+ ChatCompletionContentPartText.builder().text(userText).build()));
+ }
+ for (String dataUrl : imageDataUrls) {
+ parts.add(ChatCompletionContentPart.ofImageUrl(
+ ChatCompletionContentPartImage.builder()
+ .imageUrl(ChatCompletionContentPartImage.ImageUrl.builder().url(dataUrl).build())
+ .build()));
+ }
+
+ ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
+ .model(model)
+ .temperature(TEMPERATURE)
+ .responseFormat(ResponseFormatJsonObject.builder().build())
+ .addSystemMessage(systemPrompt)
+ .addUserMessageOfArrayOfContentParts(parts)
+ .build();
+
+ return execute(client, model, params);
+ }
+
+ /**
+ * 执行 Chat Completions 请求并解析内容与 token 用量。
+ *
+ * @param client SDK 客户端
+ * @param model 模型名称(仅用于日志)
+ * @param params 请求参数
+ * @return 模型输出内容与 token 用量
+ */
+ private DeepSeekResponse execute(OpenAIClient client, String model, ChatCompletionCreateParams params) {
+ long start = System.currentTimeMillis();
ChatCompletion completion;
try {
completion = client.chat().completions().create(params);
} catch (OpenAIException e) {
- log.warn("调用 DeepSeek 失败: model={}, error={}", model, e.getMessage());
- throw new BusinessException("调用 DeepSeek 失败: " + e.getMessage());
+ throw toBusinessException(model, e);
}
String content = completion.choices().stream()
@@ -106,18 +166,78 @@ public class DeepSeekClient {
.ifPresent(cached -> result.setCachedTokens(cached.intValue()));
}
+ /**
+ * 将 SDK 异常转换为业务异常,并记录可定位的失败信息。
+ *
+ * 超时属于可预期的外部失败,返回面向用户的友好提示;其余情况保留原始错误消息。
+ *
+ * @param model 模型名称
+ * @param e SDK 异常
+ * @return 业务异常
+ */
+ private BusinessException toBusinessException(String model, OpenAIException e) {
+ boolean timeout = isTimeout(e);
+ log.warn("调用 DeepSeek 失败: model={}, type={}, timeout={}, error={}, cause={}",
+ model, e.getClass().getSimpleName(), timeout, e.getMessage(), rootCauseMessage(e));
+ if (timeout) {
+ return new BusinessException("模型响应超时,请稍后重试或压缩图片后重试");
+ }
+ return new BusinessException("调用 DeepSeek 失败: " + e.getMessage());
+ }
+
+ /**
+ * 判断异常链中是否存在超时相关异常。
+ *
+ * @param throwable 原始异常
+ * @return 存在超时异常返回 true
+ */
+ private boolean isTimeout(Throwable throwable) {
+ Throwable current = throwable;
+ while (current != null) {
+ if (current instanceof java.net.SocketTimeoutException
+ || current instanceof java.util.concurrent.TimeoutException
+ || current instanceof java.io.InterruptedIOException) {
+ return true;
+ }
+ String name = current.getClass().getSimpleName();
+ if (name.contains("Timeout")) {
+ return true;
+ }
+ if (current.getCause() == current) {
+ break;
+ }
+ current = current.getCause();
+ }
+ return false;
+ }
+
+ /**
+ * 获取异常链最深层的原因描述。
+ *
+ * @param throwable 原始异常
+ * @return 形如「SocketTimeoutException: timeout」的描述
+ */
+ private String rootCauseMessage(Throwable throwable) {
+ Throwable current = throwable;
+ while (current.getCause() != null && current.getCause() != current) {
+ current = current.getCause();
+ }
+ return current.getClass().getSimpleName() + ": " + current.getMessage();
+ }
+
/**
* 获取(并按需构建)对应配置的 SDK 客户端。
*
* @param baseUrl API 基础地址
* @param apiKey API Key
+ * @param timeout 读取超时时间
* @return SDK 客户端
*/
- private OpenAIClient resolveClient(String baseUrl, String apiKey) {
+ private OpenAIClient resolveClient(String baseUrl, String apiKey, Duration timeout) {
String normalized = baseUrl != null && baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
- String key = normalized + "\n" + apiKey;
+ String key = normalized + "\n" + apiKey + "\n" + timeout.toSeconds();
OpenAIClient cached = clientCache.get(key);
if (cached != null) {
return cached;
@@ -128,7 +248,7 @@ public class DeepSeekClient {
return clientCache.computeIfAbsent(key, ignored -> OpenAIOkHttpClient.builder()
.baseUrl(normalized)
.apiKey(apiKey)
- .timeout(REQUEST_TIMEOUT)
+ .timeout(timeout)
.maxRetries(0)
.build());
}
diff --git a/src/main/java/cn/rosercode/finance/config/datasource/SchemaMigrator.java b/src/main/java/cn/rosercode/finance/config/datasource/SchemaMigrator.java
index b950e5c..e5fb377 100644
--- a/src/main/java/cn/rosercode/finance/config/datasource/SchemaMigrator.java
+++ b/src/main/java/cn/rosercode/finance/config/datasource/SchemaMigrator.java
@@ -25,12 +25,18 @@ public class SchemaMigrator {
/** 交易记录表名 */
private static final String TRANSACTION_TABLE = "t_transaction";
+ /** AI 配置表名 */
+ private static final String AI_CONFIG_TABLE = "t_ai_config";
+
/** 旧列名:支付方式 */
private static final String OLD_CHANNEL_COLUMN = "payment_method";
/** 新列名:交易渠道 */
private static final String NEW_CHANNEL_COLUMN = "channel";
+ /** AI 配置新增列:图片记账使用的视觉模型 */
+ private static final String VISION_MODEL_COLUMN = "vision_model";
+
/**
* 执行结构迁移。
*
@@ -40,11 +46,32 @@ public class SchemaMigrator {
public void migrate(DataSource dataSource, DataSourceType type) {
try (Connection connection = dataSource.getConnection()) {
renameChannelColumn(connection, type);
+ addVisionModelColumn(connection, type);
} catch (SQLException e) {
throw new IllegalStateException("数据库结构迁移失败:" + e.getMessage(), e);
}
}
+ /**
+ * 为 {@code t_ai_config} 补充 {@code vision_model} 列(图片记账使用的视觉模型)。
+ *
+ * 仅当列不存在时执行,保证幂等。
+ *
+ * @param connection 数据库连接
+ * @param type 数据库类型
+ * @throws SQLException 迁移失败
+ */
+ private void addVisionModelColumn(Connection connection, DataSourceType type) throws SQLException {
+ if (columnExists(connection, type, AI_CONFIG_TABLE, VISION_MODEL_COLUMN)) {
+ return;
+ }
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("ALTER TABLE " + AI_CONFIG_TABLE + " ADD COLUMN " + VISION_MODEL_COLUMN
+ + " VARCHAR(50) NOT NULL DEFAULT 'deepseek-flash'");
+ }
+ log.info("已为 {} 添加列 {}", AI_CONFIG_TABLE, VISION_MODEL_COLUMN);
+ }
+
/**
* 将 {@code t_transaction.payment_method} 重命名为 {@code channel}。
*
diff --git a/src/main/java/cn/rosercode/finance/controller/AiController.java b/src/main/java/cn/rosercode/finance/controller/AiController.java
index 214f8db..1fb1833 100644
--- a/src/main/java/cn/rosercode/finance/controller/AiController.java
+++ b/src/main/java/cn/rosercode/finance/controller/AiController.java
@@ -1,21 +1,29 @@
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.tags.Tag;
+import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.AiApplyRequest;
import cn.rosercode.finance.dto.AiCategoryRequest;
+import cn.rosercode.finance.dto.AiChannelRequest;
import cn.rosercode.finance.dto.AiConfigRequest;
import cn.rosercode.finance.dto.AiDecorateRequest;
import cn.rosercode.finance.dto.AiQueryRequest;
@@ -25,9 +33,15 @@ import cn.rosercode.finance.dto.AiRuleSuggestRequest;
import cn.rosercode.finance.dto.AiSuggestOneRequest;
import cn.rosercode.finance.dto.AiSuggestRequest;
import cn.rosercode.finance.dto.AiTagRequest;
+import cn.rosercode.finance.entity.AiImage;
+import cn.rosercode.finance.mapper.AiImageMapper;
import cn.rosercode.finance.service.AiCategoryService;
+import cn.rosercode.finance.service.AiChannelService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiDecorationService;
+import cn.rosercode.finance.service.AiImageRecordService;
+import cn.rosercode.finance.service.AiImageRecordStreamService;
+import cn.rosercode.finance.service.AiImageStorage;
import cn.rosercode.finance.service.AiQueryService;
import cn.rosercode.finance.service.AiRecordService;
import cn.rosercode.finance.service.AiRuleService;
@@ -35,10 +49,13 @@ import cn.rosercode.finance.service.AiStatisticsService;
import cn.rosercode.finance.service.AiSuggestionService;
import cn.rosercode.finance.service.AiTagService;
import cn.rosercode.finance.service.AiUsageService;
+import cn.rosercode.finance.util.ImageUtils;
import cn.rosercode.finance.util.UserContext;
import cn.rosercode.finance.vo.AiCategoryResultVO;
+import cn.rosercode.finance.vo.AiChannelResultVO;
import cn.rosercode.finance.vo.AiConfigVO;
import cn.rosercode.finance.vo.AiQueryResultVO;
+import cn.rosercode.finance.vo.AiRecordImageResultVO;
import cn.rosercode.finance.vo.AiRecordResultVO;
import cn.rosercode.finance.vo.AiRuleSuggestResultVO;
import cn.rosercode.finance.vo.AiStatisticsResultVO;
@@ -58,6 +75,9 @@ import cn.rosercode.finance.vo.AiUsageResultVO;
@Tag(name = "AI 智能建议", description = "DeepSeek 配置与交易分类标签建议相关接口")
public class AiController {
+ /** 缩略图最长边(像素),用于列表默认展示,避免加载原图 */
+ private static final int THUMBNAIL_MAX_SIZE = 1280;
+
private final AiConfigService aiConfigService;
private final AiSuggestionService aiSuggestionService;
private final AiUsageService aiUsageService;
@@ -65,15 +85,24 @@ public class AiController {
private final AiRecordService aiRecordService;
private final AiDecorationService aiDecorationService;
private final AiCategoryService aiCategoryService;
+ private final AiChannelService aiChannelService;
private final AiTagService aiTagService;
private final AiRuleService aiRuleService;
private final AiStatisticsService aiStatisticsService;
+ private final AiImageRecordService aiImageRecordService;
+ private final AiImageRecordStreamService aiImageRecordStreamService;
+ private final AiImageMapper aiImageMapper;
+ private final AiImageStorage aiImageStorage;
public AiController(AiConfigService aiConfigService, AiSuggestionService aiSuggestionService,
AiUsageService aiUsageService, AiQueryService aiQueryService,
AiRecordService aiRecordService, AiDecorationService aiDecorationService,
- AiCategoryService aiCategoryService, AiTagService aiTagService,
- AiRuleService aiRuleService, AiStatisticsService aiStatisticsService) {
+ AiCategoryService aiCategoryService, AiChannelService aiChannelService,
+ AiTagService aiTagService,
+ AiRuleService aiRuleService, AiStatisticsService aiStatisticsService,
+ AiImageRecordService aiImageRecordService,
+ AiImageRecordStreamService aiImageRecordStreamService,
+ AiImageMapper aiImageMapper, AiImageStorage aiImageStorage) {
this.aiConfigService = aiConfigService;
this.aiSuggestionService = aiSuggestionService;
this.aiUsageService = aiUsageService;
@@ -81,9 +110,14 @@ public class AiController {
this.aiRecordService = aiRecordService;
this.aiDecorationService = aiDecorationService;
this.aiCategoryService = aiCategoryService;
+ this.aiChannelService = aiChannelService;
this.aiTagService = aiTagService;
this.aiRuleService = aiRuleService;
this.aiStatisticsService = aiStatisticsService;
+ this.aiImageRecordService = aiImageRecordService;
+ this.aiImageRecordStreamService = aiImageRecordStreamService;
+ this.aiImageMapper = aiImageMapper;
+ this.aiImageStorage = aiImageStorage;
}
/**
@@ -203,6 +237,90 @@ public class AiController {
return Result.success(aiRecordService.parse(userId, recordRequest.getText()));
}
+ /**
+ * 解析图片记账内容为多条交易记录
+ *
+ * @param file 账单或支付截图
+ * @param hint 用户补充说明,可为空
+ * @return 解析结果(含图片ID与记录列表)
+ * @throws IOException 读取上传文件失败
+ */
+ @PostMapping("/record/image")
+ @Operation(summary = "图片记账解析", description = "上传一至多张账单/支付截图,由视觉模型一次性识别并生成多条交易记录,供用户确认后保存")
+ public Result recordImage(@RequestParam("files") List files,
+ @RequestParam(value = "hint", required = false) String hint)
+ throws IOException {
+ Long userId = UserContext.getUserId();
+ if (files == null || files.isEmpty()) {
+ throw new BusinessException("请选择要识别的图片");
+ }
+ List images = new ArrayList<>();
+ for (MultipartFile file : files) {
+ if (file != null && !file.isEmpty()) {
+ images.add(file.getBytes());
+ }
+ }
+ return Result.success(aiImageRecordService.parse(userId, images, hint));
+ }
+
+ /**
+ * 以 SSE 实时推送图片记账的识别进度与结果
+ *
+ * @param files 账单或支付截图(一至多张)
+ * @param hint 用户补充说明,可为空
+ * @return SSE 发射器
+ * @throws IOException 读取上传文件失败
+ */
+ @PostMapping("/record/image/stream")
+ @Operation(summary = "图片记账解析(实时进度)", description = "以 SSE 实时推送识别进度,结束时返回完整解析结果")
+ public SseEmitter recordImageStream(@RequestParam(value = "files", required = false) List files,
+ @RequestParam(value = "hint", required = false) String hint)
+ throws IOException {
+ Long userId = UserContext.getUserId();
+ List images = new ArrayList<>();
+ if (files != null) {
+ for (MultipartFile file : files) {
+ if (file != null && !file.isEmpty()) {
+ images.add(file.getBytes());
+ }
+ }
+ }
+ return aiImageRecordStreamService.stream(userId, images, hint);
+ }
+
+ /**
+ * 查看 AI 记账图片
+ *
+ * @param id 图片ID
+ * @param thumbnail 是否返回缩略图(默认 false 返回原图)
+ * @throws IOException 写出响应失败
+ */
+ @GetMapping("/image/{id}")
+ @Operation(summary = "查看 AI 记账图片", description = "按图片ID返回图片,thumbnail=true 时返回压缩后的缩略图,仅本人可访问")
+ public void image(@PathVariable Long id,
+ @RequestParam(value = "thumbnail", required = false, defaultValue = "false") boolean thumbnail)
+ throws IOException {
+ Long userId = UserContext.getUserId();
+ AiImage image = aiImageMapper.selectById(id);
+ if (image == null || !userId.equals(image.getUserId())) {
+ throw new BusinessException("图片不存在");
+ }
+ byte[] content = aiImageStorage.read(image.getFilePath());
+ String contentType = image.getMimeType() != null ? image.getMimeType() : "application/octet-stream";
+ // 缩略图统一输出 JPEG;无法解码时回退原图,保证前端仍可展示
+ if (thumbnail) {
+ byte[] compressed = ImageUtils.thumbnail(content, THUMBNAIL_MAX_SIZE);
+ if (compressed != null) {
+ content = compressed;
+ contentType = "image/jpeg";
+ }
+ }
+ HttpServletResponse response = UserContext.getResponse();
+ response.setContentType(contentType);
+ response.setContentLength(content.length);
+ response.getOutputStream().write(content);
+ }
+
/**
* 根据自然语言描述生成分类
*
@@ -229,6 +347,19 @@ public class AiController {
return Result.success(aiTagService.parse(userId, tagRequest.getText()));
}
+ /**
+ * 根据自然语言描述生成交易渠道
+ *
+ * @param channelRequest 生成请求,携带渠道用途描述
+ * @return 渠道建议
+ */
+ @PostMapping("/channel")
+ @Operation(summary = "AI 生成交易渠道", description = "根据自然语言描述生成渠道名称,供新建自定义渠道时参考")
+ public Result generateChannel(@RequestBody @Valid AiChannelRequest channelRequest) {
+ Long userId = UserContext.getUserId();
+ return Result.success(aiChannelService.parse(userId, channelRequest.getText()));
+ }
+
/**
* 根据分类名称推荐 Emoji 图标
*
diff --git a/src/main/java/cn/rosercode/finance/controller/ChannelController.java b/src/main/java/cn/rosercode/finance/controller/ChannelController.java
new file mode 100644
index 0000000..4963d17
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/controller/ChannelController.java
@@ -0,0 +1,90 @@
+package cn.rosercode.finance.controller;
+
+import java.util.List;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+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.ChannelRequest;
+import cn.rosercode.finance.service.ChannelService;
+import cn.rosercode.finance.util.UserContext;
+import cn.rosercode.finance.vo.ChannelVO;
+
+/**
+ * 交易渠道控制器
+ *
+ * 提供用户自定义交易渠道的新建、改名、删除与列表查询接口;内置渠道不可修改。
+ *
+ * @date 2026-09-21 14:10:07
+ */
+@RestController
+@RequestMapping("/api/channel")
+@Tag(name = "交易渠道管理", description = "用户自定义交易渠道的增删改查接口")
+public class ChannelController {
+
+ private final ChannelService channelService;
+
+ public ChannelController(ChannelService channelService) {
+ this.channelService = channelService;
+ }
+
+ /**
+ * 查询当前用户的自定义渠道列表
+ *
+ * @return 渠道列表(含使用账单数)
+ */
+ @GetMapping
+ @Operation(summary = "渠道列表", description = "查询当前用户的自定义交易渠道,含各自被使用的账单数")
+ public Result> list() {
+ return Result.success(channelService.list(UserContext.getUserId()));
+ }
+
+ /**
+ * 新建自定义渠道
+ *
+ * @param request 渠道名称
+ * @return 新建的渠道
+ */
+ @PostMapping
+ @Operation(summary = "新建渠道", description = "新建一个用户自定义交易渠道,同用户内名称不可重复")
+ public Result create(@RequestBody @Valid ChannelRequest request) {
+ return Result.success(channelService.create(UserContext.getUserId(), request));
+ }
+
+ /**
+ * 重命名自定义渠道
+ *
+ * @param id 渠道ID
+ * @param request 新名称
+ * @return 操作结果
+ */
+ @PutMapping("/{id}")
+ @Operation(summary = "重命名渠道", description = "修改自定义渠道名称,历史账单按编码关联,不受影响")
+ public Result rename(@PathVariable Long id, @RequestBody @Valid ChannelRequest request) {
+ channelService.rename(UserContext.getUserId(), id, request);
+ return Result.success();
+ }
+
+ /**
+ * 删除自定义渠道
+ *
+ * @param id 渠道ID
+ * @return 操作结果
+ */
+ @DeleteMapping("/{id}")
+ @Operation(summary = "删除渠道", description = "删除自定义渠道,存在账单使用时拒绝删除")
+ public Result delete(@PathVariable Long id) {
+ channelService.delete(UserContext.getUserId(), id);
+ return Result.success();
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/controller/TransactionController.java b/src/main/java/cn/rosercode/finance/controller/TransactionController.java
index 025ca60..5833207 100644
--- a/src/main/java/cn/rosercode/finance/controller/TransactionController.java
+++ b/src/main/java/cn/rosercode/finance/controller/TransactionController.java
@@ -31,13 +31,16 @@ import org.springframework.web.bind.annotation.RestController;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.BatchCategoryRequest;
+import cn.rosercode.finance.dto.BatchCreateTransactionRequest;
import cn.rosercode.finance.dto.BatchDeleteRequest;
import cn.rosercode.finance.dto.BatchTagRequest;
import cn.rosercode.finance.dto.TransactionQuery;
import cn.rosercode.finance.dto.TransactionRequest;
import cn.rosercode.finance.exporter.TransactionExportSheetHandler;
+import cn.rosercode.finance.service.ChannelService;
import cn.rosercode.finance.service.TransactionService;
import cn.rosercode.finance.util.UserContext;
+import cn.rosercode.finance.vo.ChannelOptionVO;
import cn.rosercode.finance.vo.TransactionExportVO;
import cn.rosercode.finance.vo.TransactionVO;
@@ -52,9 +55,11 @@ import cn.rosercode.finance.vo.TransactionVO;
public class TransactionController {
private final TransactionService transactionService;
+ private final ChannelService channelService;
- public TransactionController(TransactionService transactionService) {
+ public TransactionController(TransactionService transactionService, ChannelService channelService) {
this.transactionService = transactionService;
+ this.channelService = channelService;
}
/**
@@ -63,9 +68,9 @@ public class TransactionController {
* @return 交易渠道编码列表
*/
@GetMapping("/channels")
- @Operation(summary = "获取交易渠道列表", description = "返回系统支持的交易渠道编码,含账单导入来源与人工交易渠道")
- public Result> channels() {
- return Result.success(transactionService.listChannels());
+ @Operation(summary = "获取交易渠道列表", description = "返回系统内置交易渠道与当前用户自定义渠道,供表单与筛选下拉使用")
+ public Result> channels() {
+ return Result.success(channelService.listOptions(UserContext.getUserId()));
}
/**
@@ -95,6 +100,19 @@ public class TransactionController {
return Result.success();
}
+ /**
+ * 批量创建交易记录
+ *
+ * @param body 批量创建请求(记录列表、可选图片ID)
+ * @return 实际创建的记录数
+ */
+ @PostMapping("/batch")
+ @Operation(summary = "批量创建交易记录", description = "一次性创建多条交易记录,可关联 AI 记账图片")
+ public Result batchCreate(@RequestBody @Valid BatchCreateTransactionRequest body) {
+ Long userId = UserContext.getUserId();
+ return Result.success(transactionService.batchCreate(userId, body));
+ }
+
/**
* 更新交易记录
*
diff --git a/src/main/java/cn/rosercode/finance/dto/AiChannelRequest.java b/src/main/java/cn/rosercode/finance/dto/AiChannelRequest.java
new file mode 100644
index 0000000..8c34857
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/dto/AiChannelRequest.java
@@ -0,0 +1,17 @@
+package cn.rosercode.finance.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * AI 生成交易渠道请求 DTO,携带用户对渠道用途的自然语言描述。
+ *
+ * @date 2026-09-21 15:59:32
+ */
+@Data
+public class AiChannelRequest {
+
+ /** 渠道用途的自然语言描述 */
+ @NotBlank(message = "请输入渠道描述")
+ private String text;
+}
diff --git a/src/main/java/cn/rosercode/finance/dto/AiConfigRequest.java b/src/main/java/cn/rosercode/finance/dto/AiConfigRequest.java
index b8d61c9..520efc3 100644
--- a/src/main/java/cn/rosercode/finance/dto/AiConfigRequest.java
+++ b/src/main/java/cn/rosercode/finance/dto/AiConfigRequest.java
@@ -21,4 +21,7 @@ public class AiConfigRequest {
/** 模型名称,为空时使用默认值 */
private String model;
+
+ /** 图片记账使用的视觉模型名称,为空时使用默认值 */
+ private String visionModel;
}
diff --git a/src/main/java/cn/rosercode/finance/dto/BatchCreateTransactionRequest.java b/src/main/java/cn/rosercode/finance/dto/BatchCreateTransactionRequest.java
new file mode 100644
index 0000000..3acbc34
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/dto/BatchCreateTransactionRequest.java
@@ -0,0 +1,24 @@
+package cn.rosercode.finance.dto;
+
+import java.util.List;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Data;
+
+/**
+ * 批量创建交易记录请求DTO,用于图片记账等场景一次性导入多条记录。
+ *
+ * @date 2026-09-21 13:20:09
+ */
+@Data
+public class BatchCreateTransactionRequest {
+
+ /** 关联的 AI 记账图片ID,可为空 */
+ private Long imageId;
+
+ /** 待创建的交易记录列表 */
+ @NotEmpty(message = "没有可导入的记录")
+ @Valid
+ private List records;
+}
diff --git a/src/main/java/cn/rosercode/finance/dto/ChannelRequest.java b/src/main/java/cn/rosercode/finance/dto/ChannelRequest.java
new file mode 100644
index 0000000..80d13b3
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/dto/ChannelRequest.java
@@ -0,0 +1,19 @@
+package cn.rosercode.finance.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 自定义交易渠道请求DTO,用于新建或改名。
+ *
+ * @date 2026-09-21 14:10:02
+ */
+@Data
+public class ChannelRequest {
+
+ /** 渠道名称 */
+ @NotBlank(message = "请输入渠道名称")
+ @Size(max = 50, message = "渠道名称不能超过50个字符")
+ private String name;
+}
diff --git a/src/main/java/cn/rosercode/finance/dto/TransactionRequest.java b/src/main/java/cn/rosercode/finance/dto/TransactionRequest.java
index febfb6b..99f7c83 100644
--- a/src/main/java/cn/rosercode/finance/dto/TransactionRequest.java
+++ b/src/main/java/cn/rosercode/finance/dto/TransactionRequest.java
@@ -42,4 +42,10 @@ public class TransactionRequest {
/** 标签ID列表,可选;更新时为空表示清空标签 */
private List tagIds;
+
+ /** 原始数据(图片记账等场景提取的原始字段 JSON),手动记账为空 */
+ private String rawData;
+
+ /** 来源图片ID(图片记账导入时用于关联来源图片),其它场景为空 */
+ private Long imageId;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/AiConfig.java b/src/main/java/cn/rosercode/finance/entity/AiConfig.java
index 1936edd..4b637fc 100644
--- a/src/main/java/cn/rosercode/finance/entity/AiConfig.java
+++ b/src/main/java/cn/rosercode/finance/entity/AiConfig.java
@@ -40,4 +40,7 @@ public class AiConfig {
/** 使用的模型名称 */
private String model;
+
+ /** 图片记账使用的视觉模型名称 */
+ private String visionModel;
}
diff --git a/src/main/java/cn/rosercode/finance/entity/AiImage.java b/src/main/java/cn/rosercode/finance/entity/AiImage.java
new file mode 100644
index 0000000..9b122af
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/entity/AiImage.java
@@ -0,0 +1,52 @@
+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;
+
+/**
+ * AI 记账图片实体类,对应数据库表 t_ai_image,保存用户上传的账单/支付截图。
+ *
+ * @date 2026-09-21 13:20:00
+ */
+@Data
+@TableName("t_ai_image")
+public class AiImage {
+
+ /** 图片ID,主键自增 */
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ /** 创建时间 */
+ private LocalDateTime createdAt;
+
+ /** 所属用户ID */
+ private Long userId;
+
+ /** 图片本地存储相对路径 */
+ private String filePath;
+
+ /** 图片 MIME 类型 */
+ private String mimeType;
+
+ /** 图片文件大小(字节) */
+ private Long fileSize;
+
+ /** 图片宽度(像素) */
+ private Integer width;
+
+ /** 图片高度(像素) */
+ private Integer height;
+
+ /** 解析使用的视觉模型名称 */
+ private String model;
+
+ /** 解析结果摘要 */
+ private String summary;
+
+ /** 解析出的交易记录条数 */
+ private Integer recordCount;
+}
diff --git a/src/main/java/cn/rosercode/finance/entity/AiImageTransaction.java b/src/main/java/cn/rosercode/finance/entity/AiImageTransaction.java
new file mode 100644
index 0000000..7f90e18
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/entity/AiImageTransaction.java
@@ -0,0 +1,31 @@
+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;
+
+/**
+ * AI 记账图片与交易记录关联实体类,对应数据库表 t_ai_image_transaction。
+ *
+ * @date 2026-09-21 13:20:01
+ */
+@Data
+@TableName("t_ai_image_transaction")
+public class AiImageTransaction {
+
+ /** 关联ID,主键自增 */
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ /** 创建时间 */
+ private LocalDateTime createdAt;
+
+ /** AI 记账图片ID */
+ private Long imageId;
+
+ /** 交易记录ID */
+ private Long transactionId;
+}
diff --git a/src/main/java/cn/rosercode/finance/entity/Channel.java b/src/main/java/cn/rosercode/finance/entity/Channel.java
new file mode 100644
index 0000000..2f37f46
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/entity/Channel.java
@@ -0,0 +1,40 @@
+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_channel,仅存放用户自建渠道。
+ *
+ * @date 2026-09-21 14:10:00
+ */
+@Data
+@TableName("t_channel")
+public class Channel {
+
+ /** 渠道ID,主键自增 */
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ /** 创建时间 */
+ private LocalDateTime createdAt;
+
+ /** 更新时间 */
+ private LocalDateTime updatedAt;
+
+ /** 所属用户ID */
+ private Long userId;
+
+ /** 渠道编码,后端生成且稳定不变,交易记录按此关联 */
+ private String code;
+
+ /** 渠道名称,用户可修改 */
+ private String name;
+
+ /** 排序序号,数值越小越靠前 */
+ private Integer sortOrder;
+}
diff --git a/src/main/java/cn/rosercode/finance/enums/AiCallType.java b/src/main/java/cn/rosercode/finance/enums/AiCallType.java
index be43eaa..6cc918a 100644
--- a/src/main/java/cn/rosercode/finance/enums/AiCallType.java
+++ b/src/main/java/cn/rosercode/finance/enums/AiCallType.java
@@ -19,6 +19,9 @@ public enum AiCallType {
/** 自然语言记账解析 */
RECORD,
+ /** 图片记账解析 */
+ RECORD_IMAGE,
+
/** 分类图标与标签配色建议 */
DECORATE,
@@ -28,6 +31,9 @@ public enum AiCallType {
/** 自然语言生成标签 */
TAG,
+ /** 自然语言生成交易渠道 */
+ CHANNEL,
+
/** 根据账单数据生成分类与标签规则 */
RULE,
diff --git a/src/main/java/cn/rosercode/finance/enums/BillType.java b/src/main/java/cn/rosercode/finance/enums/BillType.java
index 6377241..54dfee2 100644
--- a/src/main/java/cn/rosercode/finance/enums/BillType.java
+++ b/src/main/java/cn/rosercode/finance/enums/BillType.java
@@ -5,7 +5,7 @@ import java.util.List;
/**
* 账单类型枚举,标识支持的第三方账单来源。
*
- * 当前支持支付宝、微信支付、美团、招商银行与交通银行账单,后续可扩展其他银行等账单类型。
+ *
当前支持支付宝、微信支付、美团、淘宝、京东、招商银行与交通银行账单,后续可扩展其他银行等账单类型。
* 各来源支持的账单文件扩展名(小写、含点)随枚举一并维护,供解析与上传校验复用。
*
* @date 2026-09-17 09:49:43
@@ -21,6 +21,12 @@ public enum BillType {
/** 美团账单 */
MEITUAN("美团", ".csv"),
+ /** 淘宝订单账单 */
+ TAOBAO("淘宝", ".xlsx"),
+
+ /** 京东交易流水账单 */
+ JD("京东", ".csv"),
+
/** 招商银行账单(支持 CSV 交易记录与 PDF 交易流水) */
CMB("招商银行", ".csv", ".pdf"),
diff --git a/src/main/java/cn/rosercode/finance/enums/ChannelType.java b/src/main/java/cn/rosercode/finance/enums/ChannelType.java
index f1a8d97..ce9bbbb 100644
--- a/src/main/java/cn/rosercode/finance/enums/ChannelType.java
+++ b/src/main/java/cn/rosercode/finance/enums/ChannelType.java
@@ -7,7 +7,7 @@ import java.util.stream.Collectors;
/**
* 交易渠道枚举,统一维护系统支持的交易渠道编码与中文名称。
*
- * 覆盖账单导入来源(支付宝、微信、美团、招商银行、交通银行)与人工记账渠道(现金),
+ *
覆盖账单导入来源(支付宝、微信、美团、淘宝、京东、招商银行、交通银行)与人工记账渠道(现金),
* 供交易查询、账单导入与 AI 解析等场景复用,避免各处硬编码渠道列表导致口径不一致。
*
* @date 2026-09-21 10:10:11
@@ -23,6 +23,12 @@ public enum ChannelType {
/** 美团 */
MEITUAN("美团"),
+ /** 淘宝 */
+ TAOBAO("淘宝"),
+
+ /** 京东 */
+ JD("京东"),
+
/** 招商银行 */
CMB("招商银行"),
diff --git a/src/main/java/cn/rosercode/finance/importer/JdBillParser.java b/src/main/java/cn/rosercode/finance/importer/JdBillParser.java
new file mode 100644
index 0000000..d59f5fc
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/importer/JdBillParser.java
@@ -0,0 +1,300 @@
+package cn.rosercode.finance.importer;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.Charset;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.csv.CSVFormat;
+import org.apache.commons.csv.CSVParser;
+import org.apache.commons.csv.CSVRecord;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import cn.rosercode.finance.enums.BillType;
+
+/**
+ * 京东交易流水解析器。
+ *
+ * 解析京东「交易流水」导出的 CSV 文件(通常打包为加密 zip)。文件为 UTF-8(含 BOM,兼容 GBK),
+ * 前若干行为导出信息与特别提示,之后为以「交易时间」开头的表头与数据行。
+ *
+ * 「不计收支」行(白条还款、小金库转入、全额退款等)标记为可导入但不计入统计且默认不勾选。
+ *
+ * @date 2026-09-21 12:36:50
+ */
+@Component
+public class JdBillParser implements BillParser {
+
+ /** 表头列名 */
+ private static final String COL_TIME = "交易时间";
+ private static final String COL_MERCHANT = "商户名称";
+ private static final String COL_DESC = "交易说明";
+ private static final String COL_AMOUNT = "金额";
+ private static final String COL_STATUS = "交易状态";
+ private static final String COL_DIRECTION = "收/支";
+ private static final String COL_ORDER_NO = "交易订单号";
+
+ /** 表头首列名称,用于定位表头行 */
+ private static final String HEADER_FIRST_COLUMN = COL_TIME;
+
+ /** 京东账单中的时间格式 */
+ private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ /** CSV 解析格式:逗号分隔并去除字段首尾空白(含制表符) */
+ private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
+
+ /** 金额中需要剔除的货币符号与分隔符 */
+ private static final String AMOUNT_STRIP_PATTERN = "[¥¥,\\s]";
+
+ /** JSON 序列化器,用于生成原始完整数据 */
+ private final ObjectMapper objectMapper;
+
+ public JdBillParser(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ public BillType getBillType() {
+ return BillType.JD;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 定位表头后逐行解析数据,无法解析出有效字段的行直接跳过。
+ */
+ @Override
+ public List parse(byte[] content) throws IOException {
+ String text = new String(content, detectCharset(content));
+ String[] lines = text.split("\r?\n", -1);
+
+ // 定位表头行:以「交易时间」开头
+ int headerIndex = -1;
+ for (int i = 0; i < lines.length; i++) {
+ if (lines[i].startsWith(HEADER_FIRST_COLUMN)) {
+ headerIndex = i;
+ break;
+ }
+ }
+ if (headerIndex < 0) {
+ throw new IOException("未找到京东账单表头,请确认文件是否为京东导出的交易流水");
+ }
+
+ List header = parseLine(lines[headerIndex]);
+ List rows = new ArrayList<>();
+ int rowNo = 0;
+ for (int i = headerIndex + 1; i < lines.length; i++) {
+ String trimmed = lines[i].trim();
+ if (trimmed.isEmpty()) {
+ continue;
+ }
+ List values = parseLine(lines[i]);
+ if (values.size() < 2) {
+ continue;
+ }
+ rowNo++;
+ rows.add(toRow(rowNo, header, values));
+ }
+ return rows;
+ }
+
+ /**
+ * 将一行 CSV 文本解析为字段列表
+ *
+ * @param line CSV 行文本
+ * @return 字段列表
+ * @throws IOException 解析失败
+ */
+ private List parseLine(String line) throws IOException {
+ try (CSVParser parser = CSVParser.parse(line, CSV_FORMAT)) {
+ for (CSVRecord record : parser) {
+ List values = new ArrayList<>(record.size());
+ for (String value : record) {
+ values.add(value);
+ }
+ return values;
+ }
+ }
+ return List.of();
+ }
+
+ /**
+ * 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
+ *
+ * @param rowNo 行号
+ * @param header 表头字段列表
+ * @param values 数据字段列表
+ * @return 解析后的账单行
+ */
+ private ParsedBillRow toRow(int rowNo, List header, List values) {
+ ParsedBillRow row = new ParsedBillRow();
+ row.setRowNo(rowNo);
+ row.setRawData(buildRawData(header, values));
+ // 交易订单号可能带制表符等空白,去除后作为去重标识
+ row.setExternalNo(compact(get(header, values, COL_ORDER_NO)));
+ row.setCounterparty(get(header, values, COL_MERCHANT));
+ row.setProduct(get(header, values, COL_DESC));
+ row.setTradeStatus(get(header, values, COL_STATUS));
+
+ String direction = get(header, values, COL_DIRECTION);
+ row.setDirection(direction);
+
+ String timeStr = get(header, values, COL_TIME);
+ if (StringUtils.hasText(timeStr)) {
+ try {
+ row.setTransactionTime(LocalDateTime.parse(timeStr, TIME_FORMATTER));
+ } catch (DateTimeParseException e) {
+ row.setError("交易时间格式无法解析:" + timeStr);
+ }
+ }
+
+ String amountStr = get(header, values, COL_AMOUNT);
+ if (StringUtils.hasText(amountStr)) {
+ try {
+ row.setAmount(new BigDecimal(amountStr.replaceAll(AMOUNT_STRIP_PATTERN, "")));
+ } catch (NumberFormatException e) {
+ row.setError("金额格式无法解析:" + amountStr);
+ }
+ }
+
+ boolean neutral = "不计收支".equals(direction);
+ String type = resolveType(direction, row.getProduct());
+ row.setType(type);
+ if (type == null && row.getError() == null) {
+ row.setError("无法识别收/支类型:" + direction);
+ }
+
+ // 不计收支(还款、转入、退款关闭等)默认不计入统计
+ row.setIncludeInStats(!neutral);
+
+ boolean importable = row.getError() == null
+ && StringUtils.hasText(row.getExternalNo())
+ && row.getTransactionTime() != null
+ && row.getAmount() != null
+ && row.getAmount().compareTo(BigDecimal.ZERO) > 0
+ && type != null;
+ row.setImportable(importable);
+ // 不计收支行可导入但默认不勾选
+ row.setSelected(importable && !neutral);
+ return row;
+ }
+
+ /**
+ * 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
+ *
+ * @param header 表头字段列表
+ * @param values 数据字段列表
+ * @return JSON 字符串,序列化失败时返回 null
+ */
+ private String buildRawData(List header, List values) {
+ Map map = new LinkedHashMap<>();
+ for (int i = 0; i < header.size(); i++) {
+ String key = header.get(i);
+ if (key == null || key.isEmpty()) {
+ continue;
+ }
+ String value = i < values.size() ? values.get(i) : null;
+ map.put(key, value == null ? "" : value.trim());
+ }
+ try {
+ return objectMapper.writeValueAsString(map);
+ } catch (JsonProcessingException e) {
+ // 序列化失败不影响正常导入,原始数据置空
+ return null;
+ }
+ }
+
+ /**
+ * 推断交易类型
+ *
+ * @param direction 收/支标识
+ * @param desc 交易说明
+ * @return INCOME、EXPENSE,无法识别时返回 null
+ */
+ private String resolveType(String direction, String desc) {
+ if ("支出".equals(direction)) {
+ return "EXPENSE";
+ }
+ if ("收入".equals(direction)) {
+ return "INCOME";
+ }
+ if ("不计收支".equals(direction)) {
+ // 全额退款类视为收入,其余(还款、转入等)按支出处理
+ if (StringUtils.hasText(desc) && desc.contains("退款")) {
+ return "INCOME";
+ }
+ return "EXPENSE";
+ }
+ return null;
+ }
+
+ /**
+ * 按表头列名取值
+ *
+ * @param header 表头字段列表
+ * @param values 数据字段列表
+ * @param column 列名
+ * @return 字段值,列不存在时返回 null
+ */
+ private String get(List header, List values, String column) {
+ int index = header.indexOf(column);
+ if (index < 0 || index >= values.size()) {
+ return null;
+ }
+ String value = values.get(index);
+ return value == null ? null : value.trim();
+ }
+
+ /**
+ * 去除字符串中的所有空白字符(含字段尾部的制表符)
+ *
+ * @param value 原字符串
+ * @return 去除空白后的字符串,无内容时返回 null
+ */
+ private String compact(String value) {
+ if (!StringUtils.hasText(value)) {
+ return null;
+ }
+ String result = value.replaceAll("\\s", "");
+ return result.isEmpty() ? null : result;
+ }
+
+ /**
+ * 检测账单文件的字符编码:优先 UTF-8(含 BOM),否则回退 GBK
+ *
+ * @param content 文件字节内容
+ * @return 字符集
+ */
+ private Charset detectCharset(byte[] content) {
+ // UTF-8 BOM
+ if (content.length >= 3
+ && (content[0] & 0xFF) == 0xEF
+ && (content[1] & 0xFF) == 0xBB
+ && (content[2] & 0xFF) == 0xBF) {
+ return StandardCharsets.UTF_8;
+ }
+ // 严格尝试 UTF-8 解码,失败则判定为 GBK
+ try {
+ StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(content));
+ return StandardCharsets.UTF_8;
+ } catch (CharacterCodingException e) {
+ return Charset.forName("GBK");
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/importer/TaobaoBillParser.java b/src/main/java/cn/rosercode/finance/importer/TaobaoBillParser.java
new file mode 100644
index 0000000..6910390
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/importer/TaobaoBillParser.java
@@ -0,0 +1,468 @@
+package cn.rosercode.finance.importer;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import cn.rosercode.finance.enums.BillType;
+
+/**
+ * 淘宝订单账单解析器。
+ *
+ * 解析淘宝「订单数据」导出的 xlsx 文件。第 1 行为表头,之后每行一个订单或订单下的一个商品;
+ * 多商品订单的订单级列(订单号、提交时间、状态、店铺、实付金额、运费)以合并单元格呈现,
+ * 仅首个商品行有值,其余商品行为空。解析时按订单号分组聚合,每个订单输出一条账单行。
+ *
+ * 淘宝订单均为购买支出,无收/支列;「交易关闭」与实付金额为 0 的未完成订单标记为不可导入。
+ *
+ * @date 2026-09-21 12:27:18
+ */
+@Component
+public class TaobaoBillParser implements BillParser {
+
+ /** 表头首列名称,用于定位表头行 */
+ private static final String HEADER_FIRST_COLUMN = "订单号";
+
+ /** 订单号列 */
+ private static final String COL_ORDER_NO = "订单号";
+
+ /** 订单提交时间列 */
+ private static final String COL_SUBMIT_TIME = "订单提交时间";
+
+ /** 订单状态列 */
+ private static final String COL_STATUS = "订单状态";
+
+ /** 店铺名称列 */
+ private static final String COL_SHOP = "店铺名称";
+
+ /** 商品名称列 */
+ private static final String COL_PRODUCT = "商品名称";
+
+ /** 商品数量列 */
+ private static final String COL_QUANTITY = "商品数量";
+
+ /** 商品金额列(单价) */
+ private static final String COL_ITEM_AMOUNT = "商品金额";
+
+ /** 实付金额列(订单级合计) */
+ private static final String COL_PAID_AMOUNT = "实付金额";
+
+ /** 运费列(订单级) */
+ private static final String COL_SHIPPING = "运费";
+
+ /** 订单状态:交易关闭 */
+ private static final String STATUS_CLOSED = "交易关闭";
+
+ /** 淘宝订单提交时间格式 */
+ private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ /** Excel 日期序列号基准(1900 日期系统,已规避 1900 闰年问题) */
+ private static final LocalDateTime EXCEL_EPOCH = LocalDateTime.of(1899, 12, 30, 0, 0);
+
+ /** 金额中需要剔除的货币符号与分隔符 */
+ private static final String AMOUNT_STRIP_PATTERN = "[¥¥,\\s]";
+
+ /** JSON 序列化器,用于生成原始完整数据 */
+ private final ObjectMapper objectMapper;
+
+ public TaobaoBillParser(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ public BillType getBillType() {
+ return BillType.TAOBAO;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 定位表头后按订单号分组聚合:订单号非空的行开启一个新订单,订单号为空的行视为
+ * 上一订单的后续商品行。每组订单生成一条账单行。
+ */
+ @Override
+ public List parse(byte[] content) throws IOException {
+ try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) {
+ Sheet sheet = locateSheet(workbook);
+ int headerIndex = locateHeader(sheet);
+ if (headerIndex < 0) {
+ throw new IOException("未找到淘宝订单表头,请确认文件是否为淘宝导出的订单数据");
+ }
+ List header = readHeader(sheet.getRow(headerIndex));
+
+ List groups = new ArrayList<>();
+ OrderGroup current = null;
+ for (int i = headerIndex + 1; i <= sheet.getLastRowNum(); i++) {
+ Row row = sheet.getRow(i);
+ if (row == null) {
+ continue;
+ }
+ String orderNo = readString(row.getCell(header.indexOf(COL_ORDER_NO)));
+ if (StringUtils.hasText(orderNo)) {
+ current = new OrderGroup(orderNo);
+ current.rawData = buildRawData(header, row);
+ groups.add(current);
+ }
+ if (current == null) {
+ // 表头后首行即缺少订单号,视为无效数据跳过
+ continue;
+ }
+ // 订单级字段仅首个商品行有值(合并单元格),已存在时不覆盖
+ current.fillOrderFields(header, row);
+ current.addItem(header, row);
+ }
+
+ List rows = new ArrayList<>(groups.size());
+ int rowNo = 0;
+ for (OrderGroup group : groups) {
+ rowNo++;
+ rows.add(toRow(rowNo, group));
+ }
+ return rows;
+ } catch (IOException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IOException("淘宝订单账单解析失败:" + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * 定位工作表:优先取名为「订单数据」的工作表,否则取第一个
+ *
+ * @param workbook 工作簿
+ * @return 工作表
+ */
+ private Sheet locateSheet(Workbook workbook) {
+ Sheet sheet = workbook.getSheet("订单数据");
+ return sheet != null ? sheet : workbook.getSheetAt(0);
+ }
+
+ /**
+ * 定位表头行:首个单元格以「订单号」开头的行
+ *
+ * @param sheet 工作表
+ * @return 表头行索引,未找到时返回 -1
+ */
+ private int locateHeader(Sheet sheet) {
+ for (int i = sheet.getFirstRowNum(); i <= sheet.getLastRowNum(); i++) {
+ Row row = sheet.getRow(i);
+ if (row == null) {
+ continue;
+ }
+ String first = readString(row.getCell(0));
+ if (first != null && first.startsWith(HEADER_FIRST_COLUMN)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * 读取表头行的各列名称
+ *
+ * @param row 表头行
+ * @return 列名列表
+ */
+ private List readHeader(Row row) {
+ List header = new ArrayList<>();
+ int last = row.getLastCellNum();
+ for (int c = 0; c < last; c++) {
+ String value = readString(row.getCell(c));
+ header.add(value == null ? "" : value);
+ }
+ return header;
+ }
+
+ /**
+ * 将订单分组映射为账单行,并推断类型、是否计入统计及默认勾选状态
+ *
+ * @param rowNo 行号
+ * @param group 订单分组
+ * @return 解析后的账单行
+ */
+ private ParsedBillRow toRow(int rowNo, OrderGroup group) {
+ ParsedBillRow row = new ParsedBillRow();
+ row.setRowNo(rowNo);
+ row.setExternalNo(group.orderNo);
+ row.setCounterparty(group.shop);
+ row.setProduct(group.buildProductText());
+ row.setTradeStatus(group.status);
+ row.setDirection("支出");
+ row.setType("EXPENSE");
+ row.setRawData(group.rawData);
+
+ // 交易时间
+ if (StringUtils.hasText(group.submitTime)) {
+ LocalDateTime time = parseTime(group.submitTime);
+ if (time == null) {
+ row.setError("订单提交时间格式无法解析:" + group.submitTime);
+ } else {
+ row.setTransactionTime(time);
+ }
+ } else {
+ row.setError("缺少订单提交时间");
+ }
+
+ // 金额:优先订单级实付金额;为空时按「Σ(单价×数量)+运费」回退
+ BigDecimal amount = group.resolveAmount();
+ row.setAmount(amount);
+
+ boolean closed = STATUS_CLOSED.equals(group.status);
+ // 交易关闭不计入统计;其余按来源默认值在服务层进一步收敛
+ row.setIncludeInStats(!closed);
+
+ if (closed) {
+ row.setError("交易已关闭,无需导入");
+ } else if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
+ row.setError("实付金额无效(订单可能未完成)");
+ }
+
+ boolean importable = row.getError() == null
+ && StringUtils.hasText(row.getExternalNo())
+ && row.getTransactionTime() != null
+ && row.getAmount() != null
+ && row.getAmount().compareTo(BigDecimal.ZERO) > 0;
+ row.setImportable(importable);
+ row.setSelected(importable);
+ return row;
+ }
+
+ /**
+ * 解析交易时间:支持文本时间与 Excel 日期序列号
+ *
+ * @param value 时间文本或数字字符串
+ * @return 本地日期时间,无法解析时返回 null
+ */
+ private LocalDateTime parseTime(String value) {
+ try {
+ return LocalDateTime.parse(value, TIME_FORMATTER);
+ } catch (DateTimeParseException ignored) {
+ // 尝试按 Excel 日期序列号解析
+ }
+ try {
+ BigDecimal serial = new BigDecimal(value);
+ long days = serial.longValue();
+ long seconds = Math.round(serial.subtract(BigDecimal.valueOf(days)).doubleValue() * 86400.0);
+ return EXCEL_EPOCH.plusDays(days).plusSeconds(seconds);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ /**
+ * 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
+ *
+ * @param header 表头字段列表
+ * @param row 数据行
+ * @return JSON 字符串,序列化失败时返回 null
+ */
+ private String buildRawData(List header, Row row) {
+ Map map = new LinkedHashMap<>();
+ for (int c = 0; c < header.size(); c++) {
+ String key = header.get(c);
+ if (!StringUtils.hasText(key)) {
+ continue;
+ }
+ map.put(key, readString(row.getCell(c)));
+ }
+ try {
+ return objectMapper.writeValueAsString(map);
+ } catch (JsonProcessingException e) {
+ // 序列化失败不影响正常导入,原始数据置空
+ return null;
+ }
+ }
+
+ /**
+ * 读取单元格的文本值
+ *
+ * @param cell 单元格
+ * @return 文本值,空单元格返回 null
+ */
+ private String readString(Cell cell) {
+ if (cell == null) {
+ return null;
+ }
+ CellType type = cell.getCellType();
+ if (type == CellType.STRING) {
+ String value = cell.getStringCellValue().trim();
+ return value.isEmpty() ? null : value;
+ }
+ if (type == CellType.NUMERIC) {
+ return BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros().toPlainString();
+ }
+ if (type == CellType.BOOLEAN) {
+ return String.valueOf(cell.getBooleanCellValue());
+ }
+ return null;
+ }
+
+ /**
+ * 解析金额文本:优先按数值解析,兼容带货币符号的文本
+ *
+ * @param value 金额文本
+ * @return 金额,无法解析时返回 null
+ */
+ private BigDecimal parseAmount(String value) {
+ if (!StringUtils.hasText(value)) {
+ return null;
+ }
+ try {
+ return new BigDecimal(value.replaceAll(AMOUNT_STRIP_PATTERN, ""));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ /**
+ * 解析数量文本
+ *
+ * @param value 数量文本
+ * @return 数量,无法解析时返回 1
+ */
+ private int parseQuantity(String value) {
+ if (!StringUtils.hasText(value)) {
+ return 1;
+ }
+ try {
+ return new BigDecimal(value.replaceAll(AMOUNT_STRIP_PATTERN, "")).intValue();
+ } catch (NumberFormatException e) {
+ return 1;
+ }
+ }
+
+ /**
+ * 单个订单的聚合信息,汇总订单级字段与各商品明细
+ */
+ private final class OrderGroup {
+
+ /** 订单号 */
+ private final String orderNo;
+
+ /** 订单提交时间 */
+ private String submitTime;
+
+ /** 订单状态 */
+ private String status;
+
+ /** 店铺名称 */
+ private String shop;
+
+ /** 实付金额文本(订单级) */
+ private String paidAmountText;
+
+ /** 运费文本(订单级) */
+ private String shippingText;
+
+ /** 商品名称列表 */
+ private final List products = new ArrayList<>();
+
+ /** 各商品「单价 × 数量」之和 */
+ private BigDecimal itemsTotal = BigDecimal.ZERO;
+
+ /** 原始数据(首个商品行的全部列) */
+ private String rawData;
+
+ private OrderGroup(String orderNo) {
+ this.orderNo = orderNo;
+ }
+
+ /**
+ * 填充订单级字段,已存在时不覆盖(合并单元格仅首行有值)
+ *
+ * @param header 表头
+ * @param row 数据行
+ */
+ private void fillOrderFields(List header, Row row) {
+ if (submitTime == null) {
+ submitTime = get(header, row, COL_SUBMIT_TIME);
+ }
+ if (status == null) {
+ status = get(header, row, COL_STATUS);
+ }
+ if (shop == null) {
+ shop = get(header, row, COL_SHOP);
+ }
+ if (paidAmountText == null) {
+ paidAmountText = get(header, row, COL_PAID_AMOUNT);
+ }
+ if (shippingText == null) {
+ shippingText = get(header, row, COL_SHIPPING);
+ }
+ }
+
+ /**
+ * 累加商品明细
+ *
+ * @param header 表头
+ * @param row 数据行
+ */
+ private void addItem(List header, Row row) {
+ String product = get(header, row, COL_PRODUCT);
+ if (StringUtils.hasText(product)) {
+ products.add(product);
+ }
+ BigDecimal unitPrice = parseAmount(get(header, row, COL_ITEM_AMOUNT));
+ if (unitPrice != null) {
+ int quantity = parseQuantity(get(header, row, COL_QUANTITY));
+ itemsTotal = itemsTotal.add(unitPrice.multiply(BigDecimal.valueOf(quantity)));
+ }
+ }
+
+ /**
+ * 拼接商品名称文本
+ *
+ * @return 以顿号分隔的商品名称,无商品时返回 null
+ */
+ private String buildProductText() {
+ return products.isEmpty() ? null : String.join("、", products);
+ }
+
+ /**
+ * 计算订单金额:优先实付金额,为空时回退商品合计加运费
+ *
+ * @return 订单金额,无法确定时返回 null
+ */
+ private BigDecimal resolveAmount() {
+ if (StringUtils.hasText(paidAmountText)) {
+ return parseAmount(paidAmountText);
+ }
+ BigDecimal shipping = parseAmount(shippingText);
+ return itemsTotal.add(shipping == null ? BigDecimal.ZERO : shipping);
+ }
+
+ /**
+ * 按表头列名取值
+ *
+ * @param header 表头字段列表
+ * @param row 数据行
+ * @param column 列名
+ * @return 字段值,列不存在时返回 null
+ */
+ private String get(List header, Row row, String column) {
+ int index = header.indexOf(column);
+ if (index < 0) {
+ return null;
+ }
+ return readString(row.getCell(index));
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/mapper/AiImageMapper.java b/src/main/java/cn/rosercode/finance/mapper/AiImageMapper.java
new file mode 100644
index 0000000..19bac0f
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/mapper/AiImageMapper.java
@@ -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.AiImage;
+
+/**
+ * AI 记账图片 Mapper 接口,继承 MyBatis-Plus 的 BaseMapper,提供图片表的基础 CRUD 操作。
+ *
+ * @date 2026-09-21 13:20:02
+ */
+@Mapper
+public interface AiImageMapper extends BaseMapper {
+}
diff --git a/src/main/java/cn/rosercode/finance/mapper/AiImageTransactionMapper.java b/src/main/java/cn/rosercode/finance/mapper/AiImageTransactionMapper.java
new file mode 100644
index 0000000..a5a9a51
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/mapper/AiImageTransactionMapper.java
@@ -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.AiImageTransaction;
+
+/**
+ * AI 记账图片与交易记录关联 Mapper 接口,提供关联表的基础 CRUD 操作。
+ *
+ * @date 2026-09-21 13:20:03
+ */
+@Mapper
+public interface AiImageTransactionMapper extends BaseMapper {
+}
diff --git a/src/main/java/cn/rosercode/finance/mapper/ChannelMapper.java b/src/main/java/cn/rosercode/finance/mapper/ChannelMapper.java
new file mode 100644
index 0000000..164a1fd
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/mapper/ChannelMapper.java
@@ -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.Channel;
+
+/**
+ * 用户自定义交易渠道 Mapper 接口,提供渠道表的基础 CRUD 操作。
+ *
+ * @date 2026-09-21 14:10:01
+ */
+@Mapper
+public interface ChannelMapper extends BaseMapper {
+}
diff --git a/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java b/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java
index fc3b289..b46b809 100644
--- a/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java
+++ b/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java
@@ -59,6 +59,29 @@ public class PromptTemplateService {
return render("ai-record-system", variables);
}
+ /**
+ * 渲染图片记账的系统提示词(模板 {@code ai-record-image-system})。
+ *
+ * @param typeDesc 收支类型说明
+ * @param channelDesc 交易渠道说明
+ * @param categoryNames 可用分类名称
+ * @param tagNames 可用标签名称
+ * @param now 当前时间(yyyy-MM-dd HH:mm:ss)
+ * @param exampleTime 示例交易时间(yyyy-MM-dd'T'HH:mm:ss)
+ * @return 系统提示词
+ */
+ public String recordImageSystemPrompt(String typeDesc, String channelDesc, String categoryNames,
+ String tagNames, String now, String exampleTime) {
+ Map variables = new LinkedHashMap<>();
+ variables.put("typeDesc", typeDesc);
+ variables.put("channelDesc", channelDesc);
+ variables.put("categoryNames", categoryNames);
+ variables.put("tagNames", tagNames);
+ variables.put("now", now);
+ variables.put("exampleTime", exampleTime);
+ return render("ai-record-image-system", variables);
+ }
+
/**
* 渲染自然语言查询的系统提示词(模板 {@code ai-query-system})。
*
@@ -120,6 +143,15 @@ public class PromptTemplateService {
return render("ai-tag-system", null);
}
+ /**
+ * 渲染生成交易渠道的系统提示词(模板 {@code ai-channel-system})。
+ *
+ * @return 系统提示词
+ */
+ public String channelSystemPrompt() {
+ return render("ai-channel-system", null);
+ }
+
/**
* 渲染 Emoji 图标建议的系统提示词(模板 {@code ai-decoration-emoji-system})。
*
diff --git a/src/main/java/cn/rosercode/finance/service/AiChannelService.java b/src/main/java/cn/rosercode/finance/service/AiChannelService.java
new file mode 100644
index 0000000..27362d2
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/AiChannelService.java
@@ -0,0 +1,20 @@
+package cn.rosercode.finance.service;
+
+import cn.rosercode.finance.vo.AiChannelResultVO;
+
+/**
+ * AI 生成交易渠道服务接口,将自然语言描述解析为渠道建议。
+ *
+ * @date 2026-09-21 15:59:32
+ */
+public interface AiChannelService {
+
+ /**
+ * 根据自然语言描述生成渠道建议。
+ *
+ * @param userId 用户ID
+ * @param text 渠道用途描述
+ * @return 渠道建议
+ */
+ AiChannelResultVO parse(Long userId, String text);
+}
diff --git a/src/main/java/cn/rosercode/finance/service/AiImageRecordService.java b/src/main/java/cn/rosercode/finance/service/AiImageRecordService.java
new file mode 100644
index 0000000..d0895bb
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/AiImageRecordService.java
@@ -0,0 +1,37 @@
+package cn.rosercode.finance.service;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import cn.rosercode.finance.vo.AiRecordImageResultVO;
+
+/**
+ * AI 图片记账服务接口,将账单/支付截图解析为多条交易记录。
+ *
+ * @date 2026-09-21 13:20:07
+ */
+public interface AiImageRecordService {
+
+ /**
+ * 解析图片记账内容,支持一次上传多张图片合并分析。
+ *
+ * @param userId 用户ID
+ * @param images 图片字节列表,按上传顺序排列
+ * @param hint 用户补充说明,可为空
+ * @return 解析结果(含图片ID列表与记录列表)
+ */
+ default AiRecordImageResultVO parse(Long userId, List images, String hint) {
+ return parse(userId, images, hint, null);
+ }
+
+ /**
+ * 解析图片记账内容,并回调执行进度。
+ *
+ * @param userId 用户ID
+ * @param images 图片字节列表,按上传顺序排列
+ * @param hint 用户补充说明,可为空
+ * @param progress 进度回调(接收面向用户的进度文本),可为空
+ * @return 解析结果(含图片ID列表与记录列表)
+ */
+ AiRecordImageResultVO parse(Long userId, List images, String hint, Consumer progress);
+}
diff --git a/src/main/java/cn/rosercode/finance/service/AiImageRecordStreamService.java b/src/main/java/cn/rosercode/finance/service/AiImageRecordStreamService.java
new file mode 100644
index 0000000..e921d0f
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/AiImageRecordStreamService.java
@@ -0,0 +1,132 @@
+package cn.rosercode.finance.service;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.function.Consumer;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.PreDestroy;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import cn.rosercode.finance.vo.AiRecordImageResultVO;
+
+/**
+ * AI 图片记账的实时进度推送服务。
+ *
+ * 基于 SSE 将图片识别的执行进度实时推送给前端:处理过程中逐条推送 {@code progress}
+ * 事件(面向用户的进度文本),正常结束时推送 {@code done}(携带完整解析结果 JSON),
+ * 异常时推送 {@code failed}(含失败原因)。识别在后台线程执行,避免阻塞请求线程。
+ *
+ * @date 2026-09-21 16:20:00
+ */
+@Slf4j
+@Service
+public class AiImageRecordStreamService {
+
+ private final AiImageRecordService aiImageRecordService;
+ private final ObjectMapper objectMapper;
+
+ /** 执行识别任务的守护线程池 */
+ private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> {
+ Thread thread = new Thread(runnable, "ai-image-record-stream");
+ thread.setDaemon(true);
+ return thread;
+ });
+
+ public AiImageRecordStreamService(AiImageRecordService aiImageRecordService, ObjectMapper objectMapper) {
+ this.aiImageRecordService = aiImageRecordService;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * 建立图片识别的实时进度流。
+ *
+ * @param userId 当前用户ID
+ * @param images 图片字节列表,按上传顺序排列
+ * @param hint 用户补充说明,可为空
+ * @return SSE 发射器
+ */
+ public SseEmitter stream(Long userId, List images, String hint) {
+ SseEmitter emitter = new SseEmitter(0L);
+ executor.submit(() -> run(emitter, userId, images, hint));
+ return emitter;
+ }
+
+ /**
+ * 应用关闭时停止线程池。
+ */
+ @PreDestroy
+ public void shutdown() {
+ executor.shutdownNow();
+ }
+
+ /**
+ * 执行识别并把进度与结果推送到发射器。
+ *
+ * @param emitter SSE 发射器
+ * @param userId 用户ID
+ * @param images 图片字节列表
+ * @param hint 用户补充说明
+ */
+ private void run(SseEmitter emitter, Long userId, List images, String hint) {
+ try {
+ Consumer progress = line -> trySend(emitter, "progress", line);
+ AiRecordImageResultVO result = aiImageRecordService.parse(userId, images, hint, progress);
+ trySend(emitter, "done", objectMapper.writeValueAsString(result));
+ } catch (RuntimeException | JsonProcessingException e) {
+ log.warn("图片记账流式识别失败: userId={}, error={}", userId, e.getMessage());
+ trySend(emitter, "failed", "{\"message\":\"" + escape(e.getMessage()) + "\"}");
+ } finally {
+ complete(emitter);
+ }
+ }
+
+ /**
+ * 推送一个 SSE 事件,客户端已断开时静默失败。
+ *
+ * @param emitter SSE 发射器
+ * @param event 事件名
+ * @param data 事件数据
+ */
+ private void trySend(SseEmitter emitter, String event, String data) {
+ try {
+ emitter.send(SseEmitter.event().name(event).data(data));
+ } catch (IOException | IllegalStateException e) {
+ // 连接已断开或发射器已结束,忽略
+ }
+ }
+
+ /**
+ * 结束 SSE 连接,忽略已完成或已断开的异常。
+ *
+ * @param emitter SSE 发射器
+ */
+ private void complete(SseEmitter emitter) {
+ try {
+ emitter.complete();
+ } catch (Exception ignored) {
+ // 连接可能已断开
+ }
+ }
+
+ /**
+ * 转义 JSON 字符串中的特殊字符。
+ *
+ * @param value 原始字符串
+ * @return 转义后的字符串
+ */
+ private String escape(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", " ")
+ .replace("\r", " ");
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/AiImageStorage.java b/src/main/java/cn/rosercode/finance/service/AiImageStorage.java
new file mode 100644
index 0000000..da49f5e
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/AiImageStorage.java
@@ -0,0 +1,87 @@
+package cn.rosercode.finance.service;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.UUID;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import cn.rosercode.finance.common.BusinessException;
+import cn.rosercode.finance.util.AppHome;
+import cn.rosercode.finance.util.ImageUtils.ImageType;
+
+/**
+ * AI 记账图片存储组件,负责图片文件的落盘与读取。
+ *
+ * 文件保存在应用运行目录下的 {@code data/ai-images/{userId}/{yyyyMM}/} 中,
+ * 数据库仅记录相对路径,读取时经 {@link AppHome} 解析。
+ *
+ * @date 2026-09-21 13:20:04
+ */
+@Component
+@Slf4j
+public class AiImageStorage {
+
+ /** 图片存储根目录(相对应用运行目录) */
+ private static final String ROOT = "data/ai-images";
+
+ /** 按月分目录的格式 */
+ private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
+
+ /**
+ * 保存图片文件。
+ *
+ * @param userId 用户ID
+ * @param content 图片字节
+ * @param type 图片格式
+ * @return 相对应用运行目录的存储路径
+ */
+ public String save(Long userId, byte[] content, ImageType type) {
+ String month = LocalDate.now().format(MONTH_FMT);
+ String fileName = UUID.randomUUID().toString().replace("-", "") + "." + type.getExtension();
+ Path relative = Paths.get(ROOT, String.valueOf(userId), month, fileName);
+ Path target = AppHome.getHome().resolve(relative);
+ try {
+ Files.createDirectories(target.getParent());
+ Files.write(target, content);
+ } catch (IOException e) {
+ log.error("保存 AI 记账图片失败: userId={}, path={}", userId, target, e);
+ throw new BusinessException("保存图片失败");
+ }
+ return relative.toString().replace('\\', '/');
+ }
+
+ /**
+ * 读取图片字节。
+ *
+ * @param relativePath 相对存储路径
+ * @return 图片字节
+ */
+ public byte[] read(String relativePath) {
+ Path target = AppHome.getHome().resolve(relativePath);
+ try {
+ return Files.readAllBytes(target);
+ } catch (IOException e) {
+ log.warn("读取 AI 记账图片失败: path={}", relativePath);
+ throw new BusinessException("图片不存在或无法读取");
+ }
+ }
+
+ /**
+ * 删除图片文件(文件不存在时忽略)。
+ *
+ * @param relativePath 相对存储路径
+ */
+ public void delete(String relativePath) {
+ try {
+ Files.deleteIfExists(AppHome.getHome().resolve(relativePath));
+ } catch (IOException e) {
+ log.warn("删除 AI 记账图片失败: path={}", relativePath);
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/ChannelService.java b/src/main/java/cn/rosercode/finance/service/ChannelService.java
new file mode 100644
index 0000000..ea3f776
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/ChannelService.java
@@ -0,0 +1,92 @@
+package cn.rosercode.finance.service;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import cn.rosercode.finance.dto.ChannelRequest;
+import cn.rosercode.finance.vo.ChannelOptionVO;
+import cn.rosercode.finance.vo.ChannelVO;
+
+/**
+ * 用户自定义交易渠道服务接口,提供渠道的增删改查与选项解析。
+ *
+ * @date 2026-09-21 14:10:05
+ */
+public interface ChannelService {
+
+ /**
+ * 查询当前用户的自定义渠道列表(含各自使用账单数)。
+ *
+ * @param userId 用户ID
+ * @return 渠道列表
+ */
+ List list(Long userId);
+
+ /**
+ * 新建自定义渠道。
+ *
+ * @param userId 用户ID
+ * @param request 渠道名称
+ * @return 新建的渠道
+ */
+ ChannelVO create(Long userId, ChannelRequest request);
+
+ /**
+ * 重命名自定义渠道(不影响已使用该渠道的历史账单)。
+ *
+ * @param userId 用户ID
+ * @param id 渠道ID
+ * @param request 新名称
+ */
+ void rename(Long userId, Long id, ChannelRequest request);
+
+ /**
+ * 删除自定义渠道,存在账单使用时拒绝删除。
+ *
+ * @param userId 用户ID
+ * @param id 渠道ID
+ */
+ void delete(Long userId, Long id);
+
+ /**
+ * 查询渠道选项(内置 + 自定义),供表单与筛选下拉使用。
+ *
+ * @param userId 用户ID
+ * @return 渠道选项列表
+ */
+ List listOptions(Long userId);
+
+ /**
+ * 查询当前用户自定义渠道的「编码 → 名称」映射。
+ *
+ * @param userId 用户ID
+ * @return 映射,无自定义渠道时为空 Map
+ */
+ Map nameMap(Long userId);
+
+ /**
+ * 构建供 AI 提示词使用的渠道说明,包含内置渠道与当前用户自定义渠道。
+ *
+ * @param userId 用户ID
+ * @return 形如「ALIPAY(支付宝)、CUSTOM_XXXXXXXX(自定义名)」的说明文本
+ */
+ String describeChannels(Long userId);
+
+ /**
+ * 查询合法的渠道编码集合(内置 + 当前用户自定义),用于校验 AI 返回的渠道。
+ *
+ * @param userId 用户ID
+ * @return 渠道编码集合
+ */
+ Set validCodes(Long userId);
+
+ /**
+ * 解析渠道编码对应的展示名称:优先自定义渠道,其次内置枚举,未知编码原样返回。
+ *
+ * @param userId 用户ID
+ * @param code 渠道编码
+ * @return 渠道名称
+ */
+ String resolveName(Long userId, String code);
+}
diff --git a/src/main/java/cn/rosercode/finance/service/TransactionService.java b/src/main/java/cn/rosercode/finance/service/TransactionService.java
index f54e86b..7ec6c24 100644
--- a/src/main/java/cn/rosercode/finance/service/TransactionService.java
+++ b/src/main/java/cn/rosercode/finance/service/TransactionService.java
@@ -4,6 +4,7 @@ import java.util.List;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.dto.BatchCategoryRequest;
+import cn.rosercode.finance.dto.BatchCreateTransactionRequest;
import cn.rosercode.finance.dto.BatchTagRequest;
import cn.rosercode.finance.dto.TransactionQuery;
import cn.rosercode.finance.dto.TransactionRequest;
@@ -32,6 +33,17 @@ public interface TransactionService {
*/
void create(Long userId, TransactionRequest request);
+ /**
+ * 批量创建交易记录
+ *
+ * 用于图片记账等场景一次性导入多条记录;若携带图片ID,则同时建立图片与记录的关联。
+ *
+ * @param userId 用户ID
+ * @param request 批量创建请求(记录列表、可选图片ID)
+ * @return 实际创建的记录数
+ */
+ int batchCreate(Long userId, BatchCreateTransactionRequest request);
+
/**
* 更新交易记录
*
@@ -104,14 +116,4 @@ public interface TransactionService {
* @return 涉及的记录数
*/
int batchTags(Long userId, BatchTagRequest request);
-
- /**
- * 获取系统支持的交易渠道编码列表。
- *
- * 由账单来源({@link cn.rosercode.finance.enums.BillType})与人工记账交易渠道组成,
- * 供前端筛选与编辑表单动态渲染。
- *
- * @return 交易渠道编码列表
- */
- List listChannels();
}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiChannelServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiChannelServiceImpl.java
new file mode 100644
index 0000000..f550fc4
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiChannelServiceImpl.java
@@ -0,0 +1,141 @@
+package cn.rosercode.finance.service.impl;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.stereotype.Service;
+
+import cn.rosercode.finance.client.DeepSeekClient;
+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.prompt.PromptTemplateService;
+import cn.rosercode.finance.service.AiChannelService;
+import cn.rosercode.finance.service.AiConfigService;
+import cn.rosercode.finance.service.AiUsageService;
+import cn.rosercode.finance.vo.AiChannelResultVO;
+
+/**
+ * AI 生成交易渠道服务实现类,根据自然语言描述生成渠道名称。
+ *
+ * @date 2026-09-21 15:59:32
+ */
+@Service
+public class AiChannelServiceImpl implements AiChannelService {
+
+ /** 渠道名称最大长度 */
+ private static final int NAME_MAX_LENGTH = 20;
+
+ private final AiConfigService aiConfigService;
+ private final DeepSeekClient deepSeekClient;
+ private final AiUsageService aiUsageService;
+ private final ObjectMapper objectMapper;
+ private final PromptTemplateService promptTemplateService;
+
+ public AiChannelServiceImpl(AiConfigService aiConfigService,
+ DeepSeekClient deepSeekClient,
+ AiUsageService aiUsageService,
+ ObjectMapper objectMapper,
+ PromptTemplateService promptTemplateService) {
+ this.aiConfigService = aiConfigService;
+ this.deepSeekClient = deepSeekClient;
+ this.aiUsageService = aiUsageService;
+ this.objectMapper = objectMapper;
+ this.promptTemplateService = promptTemplateService;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 将用户描述直接作为提示词内容,解析模型返回的渠道名称。
+ */
+ @Override
+ public AiChannelResultVO parse(Long userId, String text) {
+ if (text == null || text.isBlank()) {
+ throw new BusinessException("请输入渠道描述");
+ }
+ AiConfig config = requireConfig(userId);
+
+ long start = System.currentTimeMillis();
+ DeepSeekResponse aiResponse;
+ try {
+ aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
+ promptTemplateService.channelSystemPrompt(), text.trim());
+ } catch (RuntimeException e) {
+ // 失败也记录一次使用,便于排查问题
+ aiUsageService.recordFailure(userId, AiCallType.CHANNEL, config.getModel(), 0,
+ System.currentTimeMillis() - start, e.getMessage());
+ throw e;
+ }
+ aiUsageService.recordSuccess(userId, AiCallType.CHANNEL, config.getModel(), 0,
+ aiResponse, System.currentTimeMillis() - start);
+
+ return parseResult(aiResponse.getContent());
+ }
+
+ /**
+ * 解析模型返回的 JSON 并校验字段合法性。
+ *
+ * @param content 模型输出内容
+ * @return 渠道建议
+ */
+ private AiChannelResultVO parseResult(String content) {
+ JsonNode root;
+ try {
+ root = objectMapper.readTree(stripCodeFence(content));
+ } catch (JsonProcessingException e) {
+ throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
+ }
+
+ String name = root.path("name").asText("").trim();
+ if (name.isEmpty() || name.length() > NAME_MAX_LENGTH) {
+ throw new BusinessException("AI 未返回有效的渠道名称,请重试");
+ }
+
+ AiChannelResultVO vo = new AiChannelResultVO();
+ vo.setName(name);
+ vo.setReason(root.path("reason").asText(""));
+ return vo;
+ }
+
+ /**
+ * 获取并校验当前用户的 AI 配置。
+ *
+ * @param userId 用户ID
+ * @return 有效的 AI 配置
+ */
+ private AiConfig requireConfig(Long userId) {
+ AiConfig config = aiConfigService.getRawConfig(userId);
+ if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
+ throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启");
+ }
+ if (config.getApiKey() == null || config.getApiKey().isBlank()) {
+ throw new BusinessException("请先配置 DeepSeek API Key");
+ }
+ return config;
+ }
+
+ /**
+ * 去除模型可能附加的 Markdown 代码块围栏。
+ *
+ * @param content 原始内容
+ * @return 清理后的内容
+ */
+ private String stripCodeFence(String content) {
+ if (content == null) {
+ return "";
+ }
+ String cleaned = content.trim();
+ if (cleaned.startsWith("```")) {
+ int firstLineEnd = cleaned.indexOf('\n');
+ if (firstLineEnd > 0) {
+ cleaned = cleaned.substring(firstLineEnd + 1);
+ }
+ if (cleaned.endsWith("```")) {
+ cleaned = cleaned.substring(0, cleaned.length() - 3);
+ }
+ }
+ return cleaned.trim();
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiConfigServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiConfigServiceImpl.java
index 1ec0af0..afa9bc8 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiConfigServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiConfigServiceImpl.java
@@ -32,6 +32,9 @@ public class AiConfigServiceImpl implements AiConfigService {
/** 默认模型名称 */
private static final String DEFAULT_MODEL = "deepseek-chat";
+ /** 默认视觉模型名称(图片记账使用) */
+ private static final String DEFAULT_VISION_MODEL = "deepseek-flash";
+
private final AiConfigMapper aiConfigMapper;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
@@ -59,6 +62,7 @@ public class AiConfigServiceImpl implements AiConfigService {
vo.setHasApiKey(false);
vo.setBaseUrl(DEFAULT_BASE_URL);
vo.setModel(DEFAULT_MODEL);
+ vo.setVisionModel(DEFAULT_VISION_MODEL);
return vo;
}
vo.setEnabled(Boolean.TRUE.equals(config.getEnabled()));
@@ -66,6 +70,7 @@ public class AiConfigServiceImpl implements AiConfigService {
vo.setApiKey(maskApiKey(config.getApiKey()));
vo.setBaseUrl(config.getBaseUrl());
vo.setModel(config.getModel());
+ vo.setVisionModel(isBlank(config.getVisionModel()) ? DEFAULT_VISION_MODEL : config.getVisionModel());
return vo;
}
@@ -89,13 +94,16 @@ public class AiConfigServiceImpl implements AiConfigService {
}
config.setBaseUrl(isBlank(request.getBaseUrl()) ? DEFAULT_BASE_URL : request.getBaseUrl().trim());
config.setModel(isBlank(request.getModel()) ? DEFAULT_MODEL : request.getModel().trim());
+ config.setVisionModel(isBlank(request.getVisionModel()) ? DEFAULT_VISION_MODEL
+ : request.getVisionModel().trim());
if (config.getId() == null) {
aiConfigMapper.insert(config);
} else {
aiConfigMapper.updateById(config);
}
- log.info("保存 AI 配置: userId={}, enabled={}, model={}", userId, config.getEnabled(), config.getModel());
+ log.info("保存 AI 配置: userId={}, enabled={}, model={}, visionModel={}",
+ userId, config.getEnabled(), config.getModel(), config.getVisionModel());
}
/**
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiImageRecordServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiImageRecordServiceImpl.java
new file mode 100644
index 0000000..6ebd846
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiImageRecordServiceImpl.java
@@ -0,0 +1,392 @@
+package cn.rosercode.finance.service.impl;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import cn.rosercode.finance.client.DeepSeekClient;
+import cn.rosercode.finance.client.DeepSeekResponse;
+import cn.rosercode.finance.common.BusinessException;
+import cn.rosercode.finance.entity.AiConfig;
+import cn.rosercode.finance.entity.AiImage;
+import cn.rosercode.finance.entity.Category;
+import cn.rosercode.finance.entity.Tag;
+import cn.rosercode.finance.entity.Transaction;
+import cn.rosercode.finance.enums.AiCallType;
+import cn.rosercode.finance.enums.TransactionType;
+import cn.rosercode.finance.mapper.AiImageMapper;
+import cn.rosercode.finance.mapper.CategoryMapper;
+import cn.rosercode.finance.mapper.TagMapper;
+import cn.rosercode.finance.mapper.TransactionMapper;
+import cn.rosercode.finance.prompt.PromptTemplateService;
+import cn.rosercode.finance.service.AiConfigService;
+import cn.rosercode.finance.service.AiImageRecordService;
+import cn.rosercode.finance.service.AiImageStorage;
+import cn.rosercode.finance.service.AiUsageService;
+import cn.rosercode.finance.service.ChannelService;
+import cn.rosercode.finance.util.ImageUtils;
+import cn.rosercode.finance.util.ImageUtils.ImageType;
+import cn.rosercode.finance.util.TransactionDedupUtils;
+import cn.rosercode.finance.vo.AiRecordImageResultVO;
+import cn.rosercode.finance.vo.AiRecordResultVO;
+
+/**
+ * AI 图片记账服务实现类,负责图片校验、落盘、视觉模型调用与结果解析。
+ *
+ * 支持一次上传多张图片:按上传顺序逐张落库并获得图片ID,合并为一次视觉模型调用,
+ * 再按模型标注的 imageIndex 将每笔记录关联到来源图片。处理过程中通过进度回调
+ * 向调用方(如 SSE 推送服务)输出面向用户的进度文本。
+ *
+ * @date 2026-09-21 13:20:08
+ */
+@Service
+@Slf4j
+public class AiImageRecordServiceImpl implements AiImageRecordService {
+
+ /** 日期时间格式,与前端表单保持一致 */
+ private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
+
+ /** 单次最多上传的图片数量 */
+ private static final int MAX_IMAGE_COUNT = 5;
+
+ /** 单张图片大小上限(字节),与全局 multipart 限制保持一致 */
+ private static final long MAX_IMAGE_SIZE = 10 * 1024 * 1024L;
+
+ /** 单次上传图片总大小上限(字节),规避视觉接口请求体限制 */
+ private static final long MAX_TOTAL_SIZE = 30 * 1024 * 1024L;
+
+ /** 默认视觉模型名称 */
+ private static final String DEFAULT_VISION_MODEL = "deepseek-flash";
+
+ private final AiConfigService aiConfigService;
+ private final CategoryMapper categoryMapper;
+ private final TagMapper tagMapper;
+ private final DeepSeekClient deepSeekClient;
+ private final AiUsageService aiUsageService;
+ private final PromptTemplateService promptTemplateService;
+ private final AiRecordJsonParser aiRecordJsonParser;
+ private final AiImageStorage aiImageStorage;
+ private final AiImageMapper aiImageMapper;
+ private final ChannelService channelService;
+ private final TransactionMapper transactionMapper;
+
+ public AiImageRecordServiceImpl(AiConfigService aiConfigService,
+ CategoryMapper categoryMapper,
+ TagMapper tagMapper,
+ DeepSeekClient deepSeekClient,
+ AiUsageService aiUsageService,
+ PromptTemplateService promptTemplateService,
+ AiRecordJsonParser aiRecordJsonParser,
+ AiImageStorage aiImageStorage,
+ AiImageMapper aiImageMapper,
+ ChannelService channelService,
+ TransactionMapper transactionMapper) {
+ this.aiConfigService = aiConfigService;
+ this.categoryMapper = categoryMapper;
+ this.tagMapper = tagMapper;
+ this.deepSeekClient = deepSeekClient;
+ this.aiUsageService = aiUsageService;
+ this.promptTemplateService = promptTemplateService;
+ this.aiRecordJsonParser = aiRecordJsonParser;
+ this.aiImageStorage = aiImageStorage;
+ this.aiImageMapper = aiImageMapper;
+ this.channelService = channelService;
+ this.transactionMapper = transactionMapper;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 校验图片数量、大小与格式后逐张落盘保存,调用视觉模型一次性识别,解析出多条交易记录,
+ * 并按 imageIndex 关联来源图片。
+ */
+ @Override
+ public AiRecordImageResultVO parse(Long userId, List images, String hint, Consumer progress) {
+ if (images == null || images.isEmpty()) {
+ throw new BusinessException("请选择要识别的图片");
+ }
+ if (images.size() > MAX_IMAGE_COUNT) {
+ throw new BusinessException("单次最多上传 " + MAX_IMAGE_COUNT + " 张图片");
+ }
+ long totalSize = 0;
+ for (byte[] content : images) {
+ if (content == null || content.length == 0) {
+ throw new BusinessException("存在空图片,请重新选择");
+ }
+ if (content.length > MAX_IMAGE_SIZE) {
+ throw new BusinessException("单张图片大小不能超过 10MB");
+ }
+ totalSize += content.length;
+ }
+ if (totalSize > MAX_TOTAL_SIZE) {
+ throw new BusinessException("图片总大小不能超过 30MB,请减少图片数量或压缩后重试");
+ }
+ log.info("图片记账开始: userId={}, 图片数={}, 总大小={}字节", userId, images.size(), totalSize);
+ notifyProgress(progress, "收到 " + images.size() + " 张图片,共 " + readableSize(totalSize));
+
+ // 依据内容魔数逐张识别格式
+ List types = new ArrayList<>();
+ Set formatNames = new LinkedHashSet<>();
+ for (byte[] content : images) {
+ ImageType type = ImageUtils.detectType(content);
+ if (type == null) {
+ throw new BusinessException("不支持的图片格式,仅支持 JPEG/PNG/GIF/WebP");
+ }
+ types.add(type);
+ formatNames.add(type.name());
+ }
+ notifyProgress(progress, "图片格式校验通过:" + String.join("/", formatNames));
+
+ AiConfig config = aiConfigService.getRawConfig(userId);
+ if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
+ throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启");
+ }
+ if (isBlank(config.getApiKey())) {
+ throw new BusinessException("请先配置 DeepSeek API Key");
+ }
+ String visionModel = isBlank(config.getVisionModel()) ? DEFAULT_VISION_MODEL : config.getVisionModel();
+
+ // 逐张保存并落库,数据库仅记录相对路径
+ List saved = new ArrayList<>();
+ for (int i = 0; i < images.size(); i++) {
+ byte[] content = images.get(i);
+ ImageType type = types.get(i);
+ String relativePath = aiImageStorage.save(userId, content, type);
+ int[] size = ImageUtils.readSize(content);
+ AiImage image = new AiImage();
+ image.setUserId(userId);
+ image.setFilePath(relativePath);
+ image.setMimeType(type.getMimeType());
+ image.setFileSize((long) content.length);
+ if (size != null) {
+ image.setWidth(size[0]);
+ image.setHeight(size[1]);
+ }
+ image.setModel(visionModel);
+ image.setRecordCount(0);
+ aiImageMapper.insert(image);
+ saved.add(image);
+ notifyProgress(progress, "已保存第 " + (i + 1) + "/" + images.size() + " 张图片"
+ + (size != null ? "(" + size[0] + "×" + size[1] + ")" : ""));
+ }
+ log.info("图片记账图片已保存: userId={}, 图片数={}", userId, saved.size());
+
+ List categories = categoryMapper.selectList(new LambdaQueryWrapper()
+ .eq(Category::getUserId, userId));
+ List tags = tagMapper.selectList(new LambdaQueryWrapper()
+ .eq(Tag::getUserId, userId));
+ Set validChannels = channelService.validCodes(userId);
+ notifyProgress(progress, "已加载分类 " + categories.size() + " 个、标签 " + tags.size()
+ + " 个、渠道 " + validChannels.size() + " 个");
+
+ String systemPrompt = buildSystemPrompt(categories, tags, userId);
+ List dataUrls = new ArrayList<>();
+ for (int i = 0; i < images.size(); i++) {
+ dataUrls.add("data:" + types.get(i).getMimeType() + ";base64,"
+ + Base64.getEncoder().encodeToString(images.get(i)));
+ }
+ String userText = isBlank(hint) ? "请识别这些账单截图中的交易。" : hint.trim();
+
+ long start = System.currentTimeMillis();
+ log.info("图片记账调用视觉模型: userId={}, 模型={}, 图片数={}", userId, visionModel, images.size());
+ notifyProgress(progress, "开始调用视觉模型 " + visionModel + "(" + images.size() + " 张图片)");
+ DeepSeekResponse aiResponse;
+ try {
+ aiResponse = deepSeekClient.chatWithImages(config.getBaseUrl(), config.getApiKey(), visionModel,
+ systemPrompt, userText, dataUrls);
+ } catch (RuntimeException e) {
+ // 失败也记录一次使用,便于排查问题
+ aiUsageService.recordFailure(userId, AiCallType.RECORD_IMAGE, visionModel, 0,
+ System.currentTimeMillis() - start, e.getMessage());
+ log.warn("图片记账识别失败: userId={}, 图片数={}, 模型={}, error={}",
+ userId, saved.size(), visionModel, e.getMessage());
+ throw e;
+ }
+ long duration = System.currentTimeMillis() - start;
+ notifyProgress(progress, "模型返回,耗时 " + duration + "ms,tokens 输入 "
+ + aiResponse.getPromptTokens() + " / 输出 " + aiResponse.getCompletionTokens()
+ + " / 合计 " + aiResponse.getTotalTokens());
+
+ List records = aiRecordJsonParser.parseRecordList(aiResponse.getContent(), categories, tags,
+ validChannels);
+ String summary = aiRecordJsonParser.parseSummary(aiResponse.getContent());
+ List unmatched = aiRecordJsonParser.parseUnmatched(aiResponse.getContent());
+ notifyProgress(progress, "解析出 " + records.size() + " 笔交易");
+
+ // 标记疑似重复:与已有记录或本批其它记录的类型、金额、时间一致
+ markDuplicates(userId, records);
+ long duplicateCount = records.stream().filter(r -> Boolean.TRUE.equals(r.getDuplicate())).count();
+ if (duplicateCount > 0) {
+ notifyProgress(progress, "其中 " + duplicateCount + " 笔疑似重复,已默认不勾选");
+ }
+
+ // 按 imageIndex 关联来源图片,并统计每张图片识别出的记录数;越界或缺失时回退第一张
+ int[] counts = new int[saved.size()];
+ for (AiRecordResultVO record : records) {
+ int index = record.getImageIndex() == null ? 1 : record.getImageIndex();
+ if (index < 1 || index > saved.size()) {
+ index = 1;
+ }
+ record.setImageId(saved.get(index - 1).getId());
+ counts[index - 1]++;
+ }
+ if (saved.size() > 1) {
+ StringBuilder association = new StringBuilder();
+ for (int i = 0; i < counts.length; i++) {
+ if (i > 0) {
+ association.append("、");
+ }
+ association.append("图").append(i + 1).append(" ").append(counts[i]).append(" 笔");
+ }
+ notifyProgress(progress, "按来源图关联完成:" + association);
+ }
+
+ aiUsageService.recordSuccess(userId, AiCallType.RECORD_IMAGE, visionModel, records.size(),
+ aiResponse, duration);
+ log.info("图片记账识别完成: userId={}, 图片数={}, 记录数={}, 模型={}, 耗时={}ms",
+ userId, saved.size(), records.size(), visionModel, duration);
+
+ // 回填每张图片的摘要与记录数
+ for (int i = 0; i < saved.size(); i++) {
+ AiImage image = saved.get(i);
+ image.setSummary(summary);
+ image.setRecordCount(counts[i]);
+ aiImageMapper.updateById(image);
+ }
+
+ List imageIds = new ArrayList<>();
+ for (AiImage image : saved) {
+ imageIds.add(image.getId());
+ }
+ AiRecordImageResultVO vo = new AiRecordImageResultVO();
+ vo.setImageIds(imageIds);
+ vo.setSummary(summary);
+ vo.setRecords(records);
+ vo.setUnmatched(unmatched);
+ return vo;
+ }
+
+ /**
+ * 构建图片记账的系统提示词,注入可用分类、标签与当前日期时间。
+ *
+ * @param categories 用户分类列表
+ * @param tags 用户标签列表
+ * @param userId 用户ID
+ * @return 系统提示词
+ */
+ private String buildSystemPrompt(List categories, List tags, Long userId) {
+ String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
+ String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
+ LocalDateTime current = LocalDateTime.now();
+ return promptTemplateService.recordImageSystemPrompt(
+ TransactionType.describe(),
+ channelService.describeChannels(userId),
+ categoryNames.isEmpty() ? "无" : categoryNames,
+ tagNames.isEmpty() ? "无" : tagNames,
+ current.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
+ current.format(DATETIME_FMT));
+ }
+
+ /**
+ * 回调进度文本(回调为空时忽略)。
+ *
+ * @param progress 进度回调
+ * @param message 进度文本
+ */
+ private void notifyProgress(Consumer progress, String message) {
+ if (progress != null) {
+ progress.accept(message);
+ }
+ }
+
+ /**
+ * 标记疑似重复的记录:与已有交易或本批其它记录的类型、金额、交易时间一致。
+ *
+ * @param userId 用户ID
+ * @param records 识别出的记录
+ */
+ private void markDuplicates(Long userId, List records) {
+ if (records.isEmpty()) {
+ return;
+ }
+ // 批量查询同时间点的已有记录,避免逐条查询
+ Set times = new LinkedHashSet<>();
+ for (AiRecordResultVO record : records) {
+ LocalDateTime time = parseTime(record.getTransactionTime());
+ if (time != null) {
+ times.add(time);
+ }
+ }
+ Set existingKeys = new HashSet<>();
+ if (!times.isEmpty()) {
+ List existing = transactionMapper.selectList(new LambdaQueryWrapper()
+ .eq(Transaction::getUserId, userId)
+ .in(Transaction::getTransactionTime, times));
+ for (Transaction transaction : existing) {
+ String key = TransactionDedupUtils.key(transaction.getType(), transaction.getAmount(),
+ transaction.getTransactionTime());
+ if (key != null) {
+ existingKeys.add(key);
+ }
+ }
+ }
+ // 标记重复(含本批内部重复)
+ Set seen = new HashSet<>();
+ for (AiRecordResultVO record : records) {
+ String key = TransactionDedupUtils.key(record.getType(), record.getAmount(), record.getTransactionTime());
+ boolean duplicate = key != null && (existingKeys.contains(key) || !seen.add(key));
+ record.setDuplicate(duplicate);
+ }
+ }
+
+ /**
+ * 解析交易时间字符串。
+ *
+ * @param value 时间字符串
+ * @return 解析结果,无法解析时返回 null
+ */
+ private LocalDateTime parseTime(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ try {
+ return LocalDateTime.parse(value.trim());
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * 将字节数格式化为易读大小。
+ *
+ * @param bytes 字节数
+ * @return 形如「1.25 MB」的文本
+ */
+ private String readableSize(long bytes) {
+ if (bytes >= 1024 * 1024) {
+ return String.format("%.2f MB", bytes / 1024.0 / 1024.0);
+ }
+ return String.format("%.0f KB", bytes / 1024.0);
+ }
+
+ /**
+ * 判断字符串是否为空白。
+ *
+ * @param value 待判断字符串
+ * @return 为 null 或全空白时返回 true
+ */
+ private boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiQueryServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiQueryServiceImpl.java
index 2ec0c36..0c0160a 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiQueryServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiQueryServiceImpl.java
@@ -5,6 +5,7 @@ import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@@ -21,7 +22,6 @@ 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;
@@ -29,6 +29,7 @@ import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiQueryService;
import cn.rosercode.finance.service.AiUsageService;
+import cn.rosercode.finance.service.ChannelService;
import cn.rosercode.finance.vo.AiQueryResultVO;
/**
@@ -48,9 +49,6 @@ public class AiQueryServiceImpl implements AiQueryService {
/** 合法的收支类型,统一取自类型枚举 */
private static final List VALID_TYPES = TransactionType.codes();
- /** 合法的交易渠道,统一取自渠道枚举 */
- private static final List VALID_CHANNELS = ChannelType.codes();
-
private final AiConfigService aiConfigService;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
@@ -58,6 +56,7 @@ public class AiQueryServiceImpl implements AiQueryService {
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
+ private final ChannelService channelService;
public AiQueryServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
@@ -65,7 +64,8 @@ public class AiQueryServiceImpl implements AiQueryService {
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper,
- PromptTemplateService promptTemplateService) {
+ PromptTemplateService promptTemplateService,
+ ChannelService channelService) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
@@ -73,6 +73,7 @@ public class AiQueryServiceImpl implements AiQueryService {
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
+ this.channelService = channelService;
}
/**
@@ -98,7 +99,7 @@ public class AiQueryServiceImpl implements AiQueryService {
List tags = tagMapper.selectList(new LambdaQueryWrapper()
.eq(Tag::getUserId, userId));
- String systemPrompt = buildSystemPrompt(categories, tags);
+ String systemPrompt = buildSystemPrompt(categories, tags, userId);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
@@ -113,7 +114,7 @@ public class AiQueryServiceImpl implements AiQueryService {
aiUsageService.recordSuccess(userId, AiCallType.QUERY, config.getModel(), 0,
aiResponse, System.currentTimeMillis() - start);
- return parseResult(aiResponse.getContent(), categories, tags);
+ return parseResult(aiResponse.getContent(), categories, tags, channelService.validCodes(userId));
}
/**
@@ -121,14 +122,15 @@ public class AiQueryServiceImpl implements AiQueryService {
*
* @param categories 用户分类列表
* @param tags 用户标签列表
+ * @param userId 用户ID
* @return 系统提示词
*/
- private String buildSystemPrompt(List categories, List tags) {
+ private String buildSystemPrompt(List categories, List tags, Long userId) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
return promptTemplateService.querySystemPrompt(
TransactionType.describe(),
- ChannelType.describe(),
+ channelService.describeChannels(userId),
categoryNames.isEmpty() ? "无" : categoryNames,
tagNames.isEmpty() ? "无" : tagNames,
LocalDate.now().format(DATE_FMT));
@@ -142,7 +144,8 @@ public class AiQueryServiceImpl implements AiQueryService {
* @param tags 用户标签列表
* @return 结构化筛选条件
*/
- private AiQueryResultVO parseResult(String content, List categories, List tags) {
+ private AiQueryResultVO parseResult(String content, List categories, List tags,
+ Collection validChannels) {
JsonNode root;
try {
root = objectMapper.readTree(stripCodeFence(content));
@@ -153,7 +156,7 @@ public class AiQueryServiceImpl implements AiQueryService {
AiQueryResultVO vo = new AiQueryResultVO();
vo.setSummary(root.path("summary").asText(""));
vo.setTypes(filterValid(root.path("types"), VALID_TYPES));
- vo.setChannels(filterValid(root.path("channels"), VALID_CHANNELS));
+ vo.setChannels(filterValid(root.path("channels"), validChannels));
vo.setUncategorized(root.path("uncategorized").asBoolean(false));
JsonNode statsNode = root.path("includeInStats");
@@ -371,7 +374,7 @@ public class AiQueryServiceImpl implements AiQueryService {
* @param valid 合法值列表
* @return 过滤后的列表
*/
- private List filterValid(JsonNode node, List valid) {
+ private List filterValid(JsonNode node, Collection valid) {
List result = new ArrayList<>();
if (node != null && node.isArray()) {
for (JsonNode item : node) {
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiRecordJsonParser.java b/src/main/java/cn/rosercode/finance/service/impl/AiRecordJsonParser.java
new file mode 100644
index 0000000..f065235
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiRecordJsonParser.java
@@ -0,0 +1,363 @@
+package cn.rosercode.finance.service.impl;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.stereotype.Component;
+
+import cn.rosercode.finance.common.BusinessException;
+import cn.rosercode.finance.entity.Category;
+import cn.rosercode.finance.entity.Tag;
+import cn.rosercode.finance.enums.TransactionType;
+import cn.rosercode.finance.vo.AiRecordResultVO;
+
+/**
+ * AI 记账结果 JSON 解析器,供文本记账与图片记账共用。
+ *
+ * 负责去除模型输出可能携带的 Markdown 代码围栏、解析 JSON,
+ * 并将分类与标签名称映射为用户已有实体。
+ *
+ * @date 2026-09-21 13:20:05
+ */
+@Component
+public class AiRecordJsonParser {
+
+ /** 日期时间格式,与前端表单保持一致 */
+ private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
+
+ /** 合法的收支类型,统一取自类型枚举 */
+ private static final List VALID_TYPES = TransactionType.codes();
+
+ private final ObjectMapper objectMapper;
+
+ public AiRecordJsonParser(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * 解析单条记账结果(根节点即一条记录)。
+ *
+ * @param content 模型输出内容
+ * @param categories 用户分类列表
+ * @param tags 用户标签列表
+ * @param validChannels 合法渠道编码集合(内置 + 用户自定义)
+ * @return 结构化交易记录
+ */
+ public AiRecordResultVO parseRecord(String content, List categories, List tags,
+ Set validChannels) {
+ JsonNode root = readTree(content);
+ AiRecordResultVO vo = parseNode(root, categories, tags, validChannels);
+ vo.setSummary(root.path("summary").asText(""));
+ vo.setUnmatched(parseUnmatched(root));
+ if (vo.getAmount() == null || vo.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
+ throw new BusinessException("未能识别到有效金额,请补充金额后重试");
+ }
+ return vo;
+ }
+
+ /**
+ * 解析多条记账结果(根节点 {@code records} 数组)。
+ *
+ * 未识别到有效金额的条目会被跳过。
+ *
+ * @param content 模型输出内容
+ * @param categories 用户分类列表
+ * @param tags 用户标签列表
+ * @param validChannels 合法渠道编码集合(内置 + 用户自定义)
+ * @return 结构化交易记录列表
+ */
+ public List parseRecordList(String content, List categories, List tags,
+ Set validChannels) {
+ JsonNode root = readTree(content);
+ List records = new ArrayList<>();
+ JsonNode recordsNode = root.path("records");
+ if (recordsNode.isArray()) {
+ for (JsonNode node : recordsNode) {
+ AiRecordResultVO vo = parseNode(node, categories, tags, validChannels);
+ if (vo.getAmount() != null && vo.getAmount().compareTo(BigDecimal.ZERO) > 0) {
+ records.add(vo);
+ }
+ }
+ }
+ return records;
+ }
+
+ /**
+ * 解析顶层摘要。
+ *
+ * @param content 模型输出内容
+ * @return 摘要文本,缺失时为空字符串
+ */
+ public String parseSummary(String content) {
+ return readTree(content).path("summary").asText("");
+ }
+
+ /**
+ * 解析顶层未识别关键词。
+ *
+ * @param content 模型输出内容
+ * @return 未识别关键词列表
+ */
+ public List parseUnmatched(String content) {
+ return parseUnmatched(readTree(content));
+ }
+
+ /**
+ * 读取并解析 JSON,必要时去除 Markdown 代码围栏。
+ *
+ * @param content 模型输出内容
+ * @return JSON 根节点
+ */
+ private JsonNode readTree(String content) {
+ try {
+ return objectMapper.readTree(stripCodeFence(content));
+ } catch (JsonProcessingException e) {
+ throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
+ }
+ }
+
+ /**
+ * 将单个 JSON 节点映射为交易记录字段。
+ *
+ * @param root 记录节点
+ * @param categories 用户分类列表
+ * @param tags 用户标签列表
+ * @param validChannels 合法渠道编码集合
+ * @return 结构化交易记录
+ */
+ private AiRecordResultVO parseNode(JsonNode root, List categories, List tags,
+ Set validChannels) {
+ AiRecordResultVO vo = new AiRecordResultVO();
+
+ // 类型:仅接受合法值,默认支出
+ 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")));
+
+ // 分类:取第一个匹配项
+ JsonNode categoryNode = root.path("categoryNames");
+ if (categoryNode.isArray()) {
+ for (JsonNode node : categoryNode) {
+ Category category = matchCategory(categories, node.asText(""));
+ if (category != null) {
+ vo.setCategoryId(category.getId());
+ vo.setCategoryName(category.getName());
+ break;
+ }
+ }
+ }
+
+ // 标签
+ List tagIds = new ArrayList<>();
+ List tagNames = new ArrayList<>();
+ JsonNode tagNode = root.path("tagNames");
+ if (tagNode.isArray()) {
+ for (JsonNode node : tagNode) {
+ Tag tag = matchTag(tags, node.asText(""));
+ if (tag != null && !tagIds.contains(tag.getId())) {
+ tagIds.add(tag.getId());
+ tagNames.add(tag.getName());
+ }
+ }
+ }
+ vo.setTagIds(tagIds);
+ vo.setTagNames(tagNames);
+
+ // 交易渠道:编码原样匹配,自定义渠道编码区分大小写
+ String channel = root.path("channel").asText("").trim();
+ if (validChannels.contains(channel)) {
+ vo.setChannel(channel);
+ } else if (validChannels.contains(channel.toUpperCase())) {
+ vo.setChannel(channel.toUpperCase());
+ }
+
+ // 交易时间
+ vo.setTransactionTime(resolveTransactionTime(root.path("transactionTime")));
+
+ String remark = root.path("remark").asText(null);
+ vo.setRemark(isBlank(remark) ? null : remark.trim());
+
+ JsonNode statsNode = root.path("includeInStats");
+ vo.setIncludeInStats(statsNode.isMissingNode() || statsNode.isNull() ? Boolean.TRUE : statsNode.asBoolean(true));
+
+ // 来源图片序号:图片记账时模型标注(从 1 开始),非法值忽略
+ JsonNode imageIndexNode = root.path("imageIndex");
+ if (imageIndexNode.isInt() || imageIndexNode.isLong()) {
+ vo.setImageIndex(imageIndexNode.asInt());
+ } else if (imageIndexNode.isTextual()) {
+ try {
+ vo.setImageIndex(Integer.parseInt(imageIndexNode.asText("").trim()));
+ } catch (NumberFormatException ignored) {
+ // 非法序号忽略,保持为空
+ }
+ }
+
+ // 原始数据:图片记账时由模型提取的原始字段对象,序列化为 JSON 文本
+ JsonNode rawNode = root.path("rawData");
+ if (rawNode.isObject()) {
+ vo.setRawData(rawNode.toString());
+ } else if (rawNode.isTextual()) {
+ String rawText = rawNode.asText("").trim();
+ vo.setRawData(rawText.isEmpty() ? null : rawText);
+ }
+
+ return vo;
+ }
+
+ /**
+ * 解析未识别关键词节点。
+ *
+ * @param root JSON 根节点
+ * @return 未识别关键词列表
+ */
+ private List parseUnmatched(JsonNode root) {
+ List unmatched = new ArrayList<>();
+ JsonNode unmatchedNode = root.path("unmatched");
+ if (unmatchedNode.isArray()) {
+ for (JsonNode node : unmatchedNode) {
+ String value = node.asText("");
+ if (!isBlank(value)) {
+ unmatched.add(value);
+ }
+ }
+ }
+ return unmatched;
+ }
+
+ /**
+ * 解析模型给出的交易时间,无法解析时回退当前时间。
+ *
+ * @param node 模型输出的时间节点
+ * @return 格式化后的时间字符串
+ */
+ private String resolveTransactionTime(JsonNode node) {
+ LocalDateTime now = LocalDateTime.now();
+ String text = node == null || node.isMissingNode() || node.isNull() ? null : node.asText(null);
+ if (isBlank(text)) {
+ return now.format(DATETIME_FMT);
+ }
+ String value = text.trim().replace(' ', 'T');
+ try {
+ // 完整日期时间
+ return LocalDateTime.parse(value.substring(0, Math.min(value.length(), 19))).format(DATETIME_FMT);
+ } catch (Exception e) {
+ // 仅日期:补齐当前时分秒
+ try {
+ LocalDate date = LocalDate.parse(value.substring(0, 10));
+ return date.atTime(now.getHour(), now.getMinute(), now.getSecond()).format(DATETIME_FMT);
+ } catch (Exception ex) {
+ return now.format(DATETIME_FMT);
+ }
+ }
+ }
+
+ /**
+ * 按名称匹配分类:优先精确匹配,其次包含匹配。
+ *
+ * @param categories 分类列表
+ * @param name 目标名称
+ * @return 匹配到的分类,未命中返回 null
+ */
+ private Category matchCategory(List categories, String name) {
+ if (isBlank(name)) {
+ return null;
+ }
+ String target = name.trim();
+ for (Category category : categories) {
+ if (category.getName().equalsIgnoreCase(target)) {
+ return category;
+ }
+ }
+ for (Category category : categories) {
+ if (category.getName().contains(target) || target.contains(category.getName())) {
+ return category;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 按名称匹配标签:优先精确匹配,其次包含匹配。
+ *
+ * @param tags 标签列表
+ * @param name 目标名称
+ * @return 匹配到的标签,未命中返回 null
+ */
+ private Tag matchTag(List tags, String name) {
+ if (isBlank(name)) {
+ return null;
+ }
+ String target = name.trim();
+ for (Tag tag : tags) {
+ if (tag.getName().equalsIgnoreCase(target)) {
+ return tag;
+ }
+ }
+ for (Tag tag : tags) {
+ if (tag.getName().contains(target) || target.contains(tag.getName())) {
+ return tag;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 将节点转换为金额,非数字时返回 null。
+ *
+ * @param node 模型输出节点
+ * @return 金额
+ */
+ private BigDecimal decimalOrNull(JsonNode node) {
+ if (node == null || node.isMissingNode() || node.isNull()) {
+ return null;
+ }
+ try {
+ return node.decimalValue();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * 去除模型可能附加的 Markdown 代码块围栏。
+ *
+ * @param content 原始内容
+ * @return 清理后的内容
+ */
+ private String stripCodeFence(String content) {
+ if (content == null) {
+ return "";
+ }
+ String cleaned = content.trim();
+ if (cleaned.startsWith("```")) {
+ int firstLineEnd = cleaned.indexOf('\n');
+ if (firstLineEnd > 0) {
+ cleaned = cleaned.substring(firstLineEnd + 1);
+ }
+ if (cleaned.endsWith("```")) {
+ cleaned = cleaned.substring(0, cleaned.length() - 3);
+ }
+ }
+ return cleaned.trim();
+ }
+
+ /**
+ * 判断字符串是否为空白。
+ *
+ * @param value 待判断字符串
+ * @return 为 null 或全空白时返回 true
+ */
+ private boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiRecordServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiRecordServiceImpl.java
index a92e442..5fc82d2 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiRecordServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiRecordServiceImpl.java
@@ -1,17 +1,12 @@
package cn.rosercode.finance.service.impl;
-import java.math.BigDecimal;
-import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import cn.rosercode.finance.client.DeepSeekClient;
@@ -21,7 +16,6 @@ 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;
@@ -29,6 +23,7 @@ import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiRecordService;
import cn.rosercode.finance.service.AiUsageService;
+import cn.rosercode.finance.service.ChannelService;
import cn.rosercode.finance.vo.AiRecordResultVO;
/**
@@ -37,39 +32,37 @@ import cn.rosercode.finance.vo.AiRecordResultVO;
* @date 2026-09-17 18:23:25
*/
@Service
+@Slf4j
public class AiRecordServiceImpl implements AiRecordService {
/** 日期时间格式,与前端表单保持一致 */
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
- /** 合法的收支类型,统一取自类型枚举 */
- private static final List VALID_TYPES = TransactionType.codes();
-
- /** 合法的交易渠道,统一取自渠道枚举 */
- private static final List VALID_CHANNELS = ChannelType.codes();
-
private final AiConfigService aiConfigService;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
- private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
+ private final AiRecordJsonParser aiRecordJsonParser;
+ private final ChannelService channelService;
public AiRecordServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
TagMapper tagMapper,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
- ObjectMapper objectMapper,
- PromptTemplateService promptTemplateService) {
+ PromptTemplateService promptTemplateService,
+ AiRecordJsonParser aiRecordJsonParser,
+ ChannelService channelService) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
- this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
+ this.aiRecordJsonParser = aiRecordJsonParser;
+ this.channelService = channelService;
}
/**
@@ -95,7 +88,7 @@ public class AiRecordServiceImpl implements AiRecordService {
List tags = tagMapper.selectList(new LambdaQueryWrapper()
.eq(Tag::getUserId, userId));
- String systemPrompt = buildSystemPrompt(categories, tags);
+ String systemPrompt = buildSystemPrompt(categories, tags, userId);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
@@ -105,12 +98,14 @@ public class AiRecordServiceImpl implements AiRecordService {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.RECORD, config.getModel(), 0,
System.currentTimeMillis() - start, e.getMessage());
+ log.warn("AI 记账解析失败: userId={}, 模型={}, error={}", userId, config.getModel(), e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.RECORD, config.getModel(), 0,
aiResponse, System.currentTimeMillis() - start);
- return parseResult(aiResponse.getContent(), categories, tags);
+ return aiRecordJsonParser.parseRecord(aiResponse.getContent(), categories, tags,
+ channelService.validCodes(userId));
}
/**
@@ -118,226 +113,22 @@ public class AiRecordServiceImpl implements AiRecordService {
*
* @param categories 用户分类列表
* @param tags 用户标签列表
+ * @param userId 用户ID
* @return 系统提示词
*/
- private String buildSystemPrompt(List categories, List tags) {
+ private String buildSystemPrompt(List categories, List tags, Long userId) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
LocalDateTime current = LocalDateTime.now();
return promptTemplateService.recordSystemPrompt(
TransactionType.describe(),
- ChannelType.describe(),
+ channelService.describeChannels(userId),
categoryNames.isEmpty() ? "无" : categoryNames,
tagNames.isEmpty() ? "无" : tagNames,
current.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
current.format(DATETIME_FMT));
}
- /**
- * 解析模型返回的 JSON 并映射为交易记录字段。
- *
- * @param content 模型输出内容
- * @param categories 用户分类列表
- * @param tags 用户标签列表
- * @return 结构化交易记录
- */
- private AiRecordResultVO parseResult(String content, List categories, List tags) {
- JsonNode root;
- try {
- root = objectMapper.readTree(stripCodeFence(content));
- } catch (JsonProcessingException e) {
- throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
- }
-
- AiRecordResultVO vo = new AiRecordResultVO();
- vo.setSummary(root.path("summary").asText(""));
-
- // 类型:仅接受合法值,默认支出
- 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")));
- if (vo.getAmount() == null || vo.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
- throw new BusinessException("未能识别到有效金额,请补充金额后重试");
- }
-
- // 分类:取第一个匹配项
- JsonNode categoryNode = root.path("categoryNames");
- if (categoryNode.isArray()) {
- for (JsonNode node : categoryNode) {
- Category category = matchCategory(categories, node.asText(""));
- if (category != null) {
- vo.setCategoryId(category.getId());
- vo.setCategoryName(category.getName());
- break;
- }
- }
- }
-
- // 标签
- List tagIds = new ArrayList<>();
- List tagNames = new ArrayList<>();
- JsonNode tagNode = root.path("tagNames");
- if (tagNode.isArray()) {
- for (JsonNode node : tagNode) {
- Tag tag = matchTag(tags, node.asText(""));
- if (tag != null && !tagIds.contains(tag.getId())) {
- tagIds.add(tag.getId());
- tagNames.add(tag.getName());
- }
- }
- }
- vo.setTagIds(tagIds);
- vo.setTagNames(tagNames);
-
- // 交易渠道
- String channel = root.path("channel").asText("").toUpperCase();
- if (VALID_CHANNELS.contains(channel)) {
- vo.setChannel(channel);
- }
-
- // 交易时间
- vo.setTransactionTime(resolveTransactionTime(root.path("transactionTime")));
-
- String remark = root.path("remark").asText(null);
- vo.setRemark(isBlank(remark) ? null : remark.trim());
-
- JsonNode statsNode = root.path("includeInStats");
- vo.setIncludeInStats(statsNode.isMissingNode() || statsNode.isNull() ? Boolean.TRUE : statsNode.asBoolean(true));
-
- // 未识别关键词
- List unmatched = new ArrayList<>();
- JsonNode unmatchedNode = root.path("unmatched");
- if (unmatchedNode.isArray()) {
- for (JsonNode node : unmatchedNode) {
- String value = node.asText("");
- if (!isBlank(value)) {
- unmatched.add(value);
- }
- }
- }
- vo.setUnmatched(unmatched);
- return vo;
- }
-
- /**
- * 解析模型给出的交易时间,无法解析时回退当前时间。
- *
- * @param node 模型输出的时间节点
- * @return 格式化后的时间字符串
- */
- private String resolveTransactionTime(JsonNode node) {
- LocalDateTime now = LocalDateTime.now();
- String text = node == null || node.isMissingNode() || node.isNull() ? null : node.asText(null);
- if (isBlank(text)) {
- return now.format(DATETIME_FMT);
- }
- String value = text.trim().replace(' ', 'T');
- try {
- // 完整日期时间
- return LocalDateTime.parse(value.substring(0, Math.min(value.length(), 19))).format(DATETIME_FMT);
- } catch (Exception e) {
- // 仅日期:补齐当前时分秒
- try {
- LocalDate date = LocalDate.parse(value.substring(0, 10));
- return date.atTime(now.getHour(), now.getMinute(), now.getSecond()).format(DATETIME_FMT);
- } catch (Exception ex) {
- return now.format(DATETIME_FMT);
- }
- }
- }
-
- /**
- * 按名称匹配分类:优先精确匹配,其次包含匹配。
- *
- * @param categories 分类列表
- * @param name 目标名称
- * @return 匹配到的分类,未命中返回 null
- */
- private Category matchCategory(List categories, String name) {
- if (isBlank(name)) {
- return null;
- }
- String target = name.trim();
- for (Category category : categories) {
- if (category.getName().equalsIgnoreCase(target)) {
- return category;
- }
- }
- for (Category category : categories) {
- if (category.getName().contains(target) || target.contains(category.getName())) {
- return category;
- }
- }
- return null;
- }
-
- /**
- * 按名称匹配标签:优先精确匹配,其次包含匹配。
- *
- * @param tags 标签列表
- * @param name 目标名称
- * @return 匹配到的标签,未命中返回 null
- */
- private Tag matchTag(List tags, String name) {
- if (isBlank(name)) {
- return null;
- }
- String target = name.trim();
- for (Tag tag : tags) {
- if (tag.getName().equalsIgnoreCase(target)) {
- return tag;
- }
- }
- for (Tag tag : tags) {
- if (tag.getName().contains(target) || target.contains(tag.getName())) {
- return tag;
- }
- }
- return null;
- }
-
- /**
- * 将节点转换为金额,非数字时返回 null。
- *
- * @param node 模型输出节点
- * @return 金额
- */
- private BigDecimal decimalOrNull(JsonNode node) {
- if (node == null || node.isMissingNode() || node.isNull()) {
- return null;
- }
- try {
- return node.decimalValue();
- } catch (Exception e) {
- return null;
- }
- }
-
- /**
- * 去除模型可能附加的 Markdown 代码块围栏。
- *
- * @param content 原始内容
- * @return 清理后的内容
- */
- private String stripCodeFence(String content) {
- if (content == null) {
- return "";
- }
- String cleaned = content.trim();
- if (cleaned.startsWith("```")) {
- int firstLineEnd = cleaned.indexOf('\n');
- if (firstLineEnd > 0) {
- cleaned = cleaned.substring(firstLineEnd + 1);
- }
- if (cleaned.endsWith("```")) {
- cleaned = cleaned.substring(0, cleaned.length() - 3);
- }
- }
- return cleaned.trim();
- }
-
/**
* 判断字符串是否为空白。
*
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiStatisticsServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiStatisticsServiceImpl.java
index 0ab124c..37e8893 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiStatisticsServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiStatisticsServiceImpl.java
@@ -6,6 +6,7 @@ import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@@ -22,7 +23,6 @@ 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.StatisticsDimension;
import cn.rosercode.finance.enums.StatisticsScope;
import cn.rosercode.finance.enums.TransactionType;
@@ -32,6 +32,7 @@ import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiStatisticsService;
import cn.rosercode.finance.service.AiUsageService;
+import cn.rosercode.finance.service.ChannelService;
import cn.rosercode.finance.vo.AiStatisticsResultVO;
/**
@@ -60,9 +61,6 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
.map(dimension -> dimension.name().toLowerCase())
.collect(Collectors.toList());
- /** 合法的交易渠道,统一取自渠道枚举 */
- private static final List VALID_CHANNELS = ChannelType.codes();
-
private final AiConfigService aiConfigService;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
@@ -70,6 +68,7 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
+ private final ChannelService channelService;
public AiStatisticsServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
@@ -77,7 +76,8 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper,
- PromptTemplateService promptTemplateService) {
+ PromptTemplateService promptTemplateService,
+ ChannelService channelService) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
@@ -85,6 +85,7 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
+ this.channelService = channelService;
}
/**
@@ -110,7 +111,7 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
List tags = tagMapper.selectList(new LambdaQueryWrapper()
.eq(Tag::getUserId, userId));
- String systemPrompt = buildSystemPrompt(categories, tags);
+ String systemPrompt = buildSystemPrompt(categories, tags, userId);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
@@ -125,7 +126,7 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
aiUsageService.recordSuccess(userId, AiCallType.STATISTICS, config.getModel(), 0,
aiResponse, System.currentTimeMillis() - start);
- return parseResult(aiResponse.getContent(), categories, tags);
+ return parseResult(aiResponse.getContent(), categories, tags, channelService.validCodes(userId));
}
/**
@@ -133,14 +134,15 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
*
* @param categories 用户分类列表
* @param tags 用户标签列表
+ * @param userId 用户ID
* @return 系统提示词
*/
- private String buildSystemPrompt(List categories, List tags) {
+ private String buildSystemPrompt(List categories, List tags, Long userId) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
return promptTemplateService.statisticsSystemPrompt(
TransactionType.describe(),
- ChannelType.describe(),
+ channelService.describeChannels(userId),
categoryNames.isEmpty() ? "无" : categoryNames,
tagNames.isEmpty() ? "无" : tagNames,
LocalDate.now().format(DATE_FMT));
@@ -154,7 +156,8 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
* @param tags 用户标签列表
* @return 统计查询参数
*/
- private AiStatisticsResultVO parseResult(String content, List categories, List tags) {
+ private AiStatisticsResultVO parseResult(String content, List categories, List tags,
+ Collection validChannels) {
JsonNode root;
try {
root = objectMapper.readTree(stripCodeFence(content));
@@ -165,7 +168,7 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
AiStatisticsResultVO vo = new AiStatisticsResultVO();
vo.setSummary(root.path("summary").asText(""));
vo.setTypes(filterValid(root.path("types"), VALID_TYPES));
- vo.setChannels(filterValid(root.path("channels"), VALID_CHANNELS));
+ vo.setChannels(filterValid(root.path("channels"), validChannels));
vo.setMinAmount(decimalOrNull(root.path("minAmount")));
vo.setMaxAmount(decimalOrNull(root.path("maxAmount")));
@@ -382,7 +385,7 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
* @param valid 合法值列表
* @return 过滤后的列表
*/
- private List filterValid(JsonNode node, List valid) {
+ private List filterValid(JsonNode node, Collection valid) {
List result = new ArrayList<>();
if (node != null && node.isArray()) {
for (JsonNode item : node) {
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiSuggestionServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiSuggestionServiceImpl.java
index 29ec7a9..8846b5e 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiSuggestionServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiSuggestionServiceImpl.java
@@ -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.ChannelType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
@@ -40,8 +41,10 @@ import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiSuggestionService;
import cn.rosercode.finance.service.AiUsageService;
+import cn.rosercode.finance.service.ChannelService;
import cn.rosercode.finance.vo.AiNewTagVO;
import cn.rosercode.finance.vo.AiSuggestionVO;
+import cn.rosercode.finance.vo.ChannelOptionVO;
/**
* AI 建议服务实现类,负责组装提示词、调用 DeepSeek、校验并应用建议。
@@ -78,6 +81,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
private final ObjectMapper objectMapper;
private final AiUsageService aiUsageService;
private final PromptTemplateService promptTemplateService;
+ private final ChannelService channelService;
public AiSuggestionServiceImpl(AiConfigService aiConfigService,
TransactionMapper transactionMapper,
@@ -87,7 +91,8 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
DeepSeekClient deepSeekClient,
ObjectMapper objectMapper,
AiUsageService aiUsageService,
- PromptTemplateService promptTemplateService) {
+ PromptTemplateService promptTemplateService,
+ ChannelService channelService) {
this.aiConfigService = aiConfigService;
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
@@ -97,6 +102,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
this.objectMapper = objectMapper;
this.aiUsageService = aiUsageService;
this.promptTemplateService = promptTemplateService;
+ this.channelService = channelService;
}
/**
@@ -153,7 +159,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
List representatives = batch.stream()
.map(group -> group.get(0))
.collect(Collectors.toList());
- String userPrompt = buildUserPrompt(categories, tags, representatives);
+ String userPrompt = buildUserPrompt(categories, tags, representatives, null);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
@@ -219,8 +225,10 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
transaction.setChannel(request.getChannel());
transaction.setRemark(request.getRemark());
List single = List.of(transaction);
+ // 渠道候选:内置渠道取中文名,自定义渠道取用户设置的名称
+ List channels = channelService.listOptions(userId);
- String userPrompt = buildUserPrompt(categories, tags, single);
+ String userPrompt = buildUserPrompt(categories, tags, single, channels);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
@@ -234,7 +242,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
aiUsageService.recordSuccess(userId, AiCallType.SUGGEST, config.getModel(), 1,
aiResponse, System.currentTimeMillis() - start);
- return parseSingleSuggestion(aiResponse.getContent(), transaction, categories, tags);
+ return parseSingleSuggestion(aiResponse.getContent(), transaction, categories, tags, channels);
}
/**
@@ -244,12 +252,14 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
* @param transaction 临时记录
* @param categories 用户分类列表
* @param tags 用户标签列表
+ * @param channels 可选渠道列表
* @return 建议结果,无有效建议时返回 null
*/
private AiSuggestionVO parseSingleSuggestion(String content,
Transaction transaction,
List categories,
- List tags) {
+ List tags,
+ List channels) {
JsonNode root;
try {
root = objectMapper.readTree(stripCodeFence(content));
@@ -299,10 +309,23 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
}
vo.setTagIds(tagIds);
vo.setTagNames(tagNames);
+
+ // 渠道建议:仅接受候选列表中的编码,非法编码直接忽略
+ String channel = node.path("channel").asText(null);
+ if (channel != null && !channel.isBlank()) {
+ channels.stream()
+ .filter(option -> channel.equals(option.getCode()))
+ .findFirst()
+ .ifPresent(option -> {
+ vo.setChannel(option.getCode());
+ vo.setChannelName(option.getName());
+ });
+ }
+
vo.setReason(node.path("reason").asText(""));
// 至少存在一项有效建议才返回
- if (vo.getCategoryId() == null && tagIds.isEmpty()) {
+ if (vo.getCategoryId() == null && tagIds.isEmpty() && vo.getChannel() == null) {
return null;
}
return vo;
@@ -558,7 +581,8 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
* @param transactions 待分析记录列表
* @return JSON 格式的用户提示词
*/
- private String buildUserPrompt(List categories, List tags, List transactions) {
+ private String buildUserPrompt(List categories, List tags, List transactions,
+ List channels) {
Map payload = new LinkedHashMap<>();
payload.put("categories", categories.stream().map(category -> {
Map node = new LinkedHashMap<>();
@@ -573,6 +597,16 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
node.put("name", tag.getName());
return node;
}).collect(Collectors.toList()));
+ // 渠道候选仅在单条建议时下发,批量建议保持原有载荷
+ if (channels != null && !channels.isEmpty()) {
+ payload.put("channels", channels.stream().map(option -> {
+ Map node = new LinkedHashMap<>();
+ node.put("code", option.getCode());
+ // 内置渠道名称为空时回填枚举中文名,便于模型理解
+ node.put("name", option.getName() != null ? option.getName() : ChannelType.labelOf(option.getCode()));
+ return node;
+ }).collect(Collectors.toList()));
+ }
// 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息
payload.put("transactions", transactions.stream().map(transaction -> {
Map node = new LinkedHashMap<>();
diff --git a/src/main/java/cn/rosercode/finance/service/impl/ChannelServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/ChannelServiceImpl.java
new file mode 100644
index 0000000..5422f13
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/service/impl/ChannelServiceImpl.java
@@ -0,0 +1,303 @@
+package cn.rosercode.finance.service.impl;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ThreadLocalRandom;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import cn.rosercode.finance.common.BusinessException;
+import cn.rosercode.finance.dto.ChannelRequest;
+import cn.rosercode.finance.entity.Channel;
+import cn.rosercode.finance.entity.Transaction;
+import cn.rosercode.finance.enums.ChannelType;
+import cn.rosercode.finance.mapper.ChannelMapper;
+import cn.rosercode.finance.mapper.TransactionMapper;
+import cn.rosercode.finance.service.ChannelService;
+import cn.rosercode.finance.vo.ChannelOptionVO;
+import cn.rosercode.finance.vo.ChannelVO;
+
+/**
+ * 用户自定义交易渠道服务实现类。
+ *
+ * 渠道编码由后端生成且稳定不变,交易记录按编码关联,因此改名不影响历史账单,
+ * 而删除前需确保没有任何账单使用该渠道。
+ *
+ * @date 2026-09-21 14:10:06
+ */
+@Service
+@Slf4j
+public class ChannelServiceImpl implements ChannelService {
+
+ /** 自定义渠道编码前缀 */
+ private static final String CODE_PREFIX = "CUSTOM_";
+
+ /** 编码随机部分长度 */
+ private static final int CODE_RANDOM_LENGTH = 8;
+
+ /** 编码随机字符集 */
+ private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+
+ /** 编码生成的最大重试次数 */
+ private static final int MAX_GENERATE_ATTEMPTS = 10;
+
+ private final ChannelMapper channelMapper;
+ private final TransactionMapper transactionMapper;
+
+ public ChannelServiceImpl(ChannelMapper channelMapper, TransactionMapper transactionMapper) {
+ this.channelMapper = channelMapper;
+ this.transactionMapper = transactionMapper;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 逐渠道统计使用账单数,便于前端提示能否删除。
+ */
+ @Override
+ public List list(Long userId) {
+ List channels = channelMapper.selectList(new LambdaQueryWrapper()
+ .eq(Channel::getUserId, userId)
+ .orderByAsc(Channel::getSortOrder)
+ .orderByAsc(Channel::getId));
+ List result = new ArrayList<>();
+ for (Channel channel : channels) {
+ ChannelVO vo = new ChannelVO();
+ vo.setId(channel.getId());
+ vo.setCode(channel.getCode());
+ vo.setName(channel.getName());
+ vo.setTransactionCount(countTransactions(userId, channel.getCode()));
+ result.add(vo);
+ }
+ return result;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 校验同用户内名称唯一后生成稳定编码并落库。
+ */
+ @Override
+ @Transactional
+ public ChannelVO create(Long userId, ChannelRequest request) {
+ String name = request.getName().trim();
+ if (existsName(userId, name, null)) {
+ throw new BusinessException("渠道名称已存在");
+ }
+ Channel channel = new Channel();
+ channel.setUserId(userId);
+ channel.setCode(generateCode(userId));
+ channel.setName(name);
+ channel.setSortOrder(0);
+ channelMapper.insert(channel);
+ log.info("新增自定义交易渠道: userId={}, id={}, code={}, name={}",
+ userId, channel.getId(), channel.getCode(), name);
+
+ ChannelVO vo = new ChannelVO();
+ vo.setId(channel.getId());
+ vo.setCode(channel.getCode());
+ vo.setName(channel.getName());
+ vo.setTransactionCount(0L);
+ return vo;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 仅更新渠道名称,编码不变,历史账单展示会同步为新名称。
+ */
+ @Override
+ @Transactional
+ public void rename(Long userId, Long id, ChannelRequest request) {
+ Channel channel = requireOwned(userId, id);
+ String name = request.getName().trim();
+ if (existsName(userId, name, id)) {
+ throw new BusinessException("渠道名称已存在");
+ }
+ channel.setName(name);
+ channelMapper.updateById(channel);
+ log.info("重命名自定义交易渠道: userId={}, id={}, name={}", userId, id, name);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 存在账单使用该渠道时拒绝删除,避免历史记录出现无效渠道。
+ */
+ @Override
+ @Transactional
+ public void delete(Long userId, Long id) {
+ Channel channel = requireOwned(userId, id);
+ long used = countTransactions(userId, channel.getCode());
+ if (used > 0) {
+ throw new BusinessException("该渠道已被 " + used + " 笔账单使用,无法删除");
+ }
+ channelMapper.deleteById(id);
+ log.info("删除自定义交易渠道: userId={}, id={}, code={}", userId, id, channel.getCode());
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 内置渠道在前(名称为空,由前端按语言渲染),自定义渠道按排序追加在后。
+ */
+ @Override
+ public List listOptions(Long userId) {
+ List options = new ArrayList<>();
+ for (ChannelType type : ChannelType.values()) {
+ options.add(ChannelOptionVO.builtin(type.name()));
+ }
+ for (Channel channel : channelMapper.selectList(new LambdaQueryWrapper()
+ .eq(Channel::getUserId, userId)
+ .orderByAsc(Channel::getSortOrder)
+ .orderByAsc(Channel::getId))) {
+ options.add(ChannelOptionVO.custom(channel.getCode(), channel.getName()));
+ }
+ return options;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Map nameMap(Long userId) {
+ Map map = new HashMap<>();
+ for (Channel channel : channelMapper.selectList(new LambdaQueryWrapper()
+ .eq(Channel::getUserId, userId))) {
+ map.put(channel.getCode(), channel.getName());
+ }
+ return map;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String describeChannels(Long userId) {
+ StringBuilder sb = new StringBuilder(ChannelType.describe());
+ for (Channel channel : channelMapper.selectList(new LambdaQueryWrapper()
+ .eq(Channel::getUserId, userId)
+ .orderByAsc(Channel::getSortOrder)
+ .orderByAsc(Channel::getId))) {
+ sb.append("、").append(channel.getCode()).append("(").append(channel.getName()).append(")");
+ }
+ return sb.toString();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Set validCodes(Long userId) {
+ Set codes = new HashSet<>(ChannelType.codes());
+ codes.addAll(nameMap(userId).keySet());
+ return codes;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String resolveName(Long userId, String code) {
+ if (code == null || code.isBlank()) {
+ return code;
+ }
+ Map custom = nameMap(userId);
+ String name = custom.get(code);
+ if (name != null) {
+ return name;
+ }
+ // 兼容历史 BANK 渠道
+ if ("BANK".equals(code)) {
+ return "银行卡";
+ }
+ return ChannelType.labelOf(code);
+ }
+
+ /**
+ * 查询归属当前用户的渠道,不存在或不属于该用户时抛业务异常。
+ *
+ * @param userId 用户ID
+ * @param id 渠道ID
+ * @return 渠道实体
+ */
+ private Channel requireOwned(Long userId, Long id) {
+ Channel channel = channelMapper.selectById(id);
+ if (channel == null || !channel.getUserId().equals(userId)) {
+ throw new BusinessException("渠道不存在");
+ }
+ return channel;
+ }
+
+ /**
+ * 判断同用户下是否已存在指定名称的渠道。
+ *
+ * @param userId 用户ID
+ * @param name 渠道名称
+ * @param excludeId 需排除的渠道ID(改名时排除自身)
+ * @return 已存在返回 true
+ */
+ private boolean existsName(Long userId, String name, Long excludeId) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(Channel::getUserId, userId)
+ .eq(Channel::getName, name);
+ if (excludeId != null) {
+ wrapper.ne(Channel::getId, excludeId);
+ }
+ Long count = channelMapper.selectCount(wrapper);
+ return count != null && count > 0;
+ }
+
+ /**
+ * 统计当前用户使用指定渠道的账单数量。
+ *
+ * @param userId 用户ID
+ * @param code 渠道编码
+ * @return 账单数量
+ */
+ private long countTransactions(Long userId, String code) {
+ Long count = transactionMapper.selectCount(new LambdaQueryWrapper()
+ .eq(Transaction::getUserId, userId)
+ .eq(Transaction::getChannel, code));
+ return count == null ? 0L : count;
+ }
+
+ /**
+ * 生成当前用户内唯一的渠道编码。
+ *
+ * @param userId 用户ID
+ * @return 渠道编码
+ */
+ private String generateCode(Long userId) {
+ for (int i = 0; i < MAX_GENERATE_ATTEMPTS; i++) {
+ String code = CODE_PREFIX + randomToken();
+ Long count = channelMapper.selectCount(new LambdaQueryWrapper()
+ .eq(Channel::getUserId, userId)
+ .eq(Channel::getCode, code));
+ if (count == null || count == 0) {
+ return code;
+ }
+ }
+ throw new BusinessException("生成渠道编码失败,请重试");
+ }
+
+ /**
+ * 生成随机编码片段。
+ *
+ * @return 随机字符串
+ */
+ private String randomToken() {
+ StringBuilder sb = new StringBuilder(CODE_RANDOM_LENGTH);
+ for (int i = 0; i < CODE_RANDOM_LENGTH; i++) {
+ sb.append(CODE_CHARS.charAt(ThreadLocalRandom.current().nextInt(CODE_CHARS.length())));
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/NotificationChannelServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/NotificationChannelServiceImpl.java
index 0551e30..914ef80 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/NotificationChannelServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/NotificationChannelServiceImpl.java
@@ -8,6 +8,7 @@ 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -26,6 +27,7 @@ import cn.rosercode.finance.vo.NotificationChannelVO;
* @date 2026-09-18 12:38:09
*/
@Service
+@Slf4j
public class NotificationChannelServiceImpl implements NotificationChannelService {
private final NotificationChannelMapper notificationChannelMapper;
@@ -66,6 +68,7 @@ public class NotificationChannelServiceImpl implements NotificationChannelServic
channel.setEnabled(request.getEnabled() == null || request.getEnabled());
channel.setConfig(mergeConfig(request.getConfig(), null));
notificationChannelMapper.insert(channel);
+ log.info("新增通知渠道: userId={}, id={}, type={}", userId, channel.getId(), type);
return channel.getId();
}
@@ -87,6 +90,7 @@ public class NotificationChannelServiceImpl implements NotificationChannelServic
}
channel.setConfig(mergeConfig(request.getConfig(), oldConfig));
notificationChannelMapper.updateById(channel);
+ log.info("更新通知渠道: userId={}, id={}, type={}", userId, channelId, type);
}
/**
@@ -97,6 +101,7 @@ public class NotificationChannelServiceImpl implements NotificationChannelServic
public void deleteChannel(Long userId, Long channelId) {
getRawChannel(userId, channelId);
notificationChannelMapper.deleteById(channelId);
+ log.info("删除通知渠道: userId={}, id={}", userId, channelId);
}
/**
diff --git a/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java
index a2deec7..88f204d 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/StatisticsServiceImpl.java
@@ -30,6 +30,7 @@ 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.ChannelService;
import cn.rosercode.finance.service.StatisticsService;
import cn.rosercode.finance.vo.CategoryStatisticsVO;
import cn.rosercode.finance.vo.MonthlyStatisticsVO;
@@ -49,10 +50,13 @@ public class StatisticsServiceImpl implements StatisticsService {
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
+ private final ChannelService channelService;
- public StatisticsServiceImpl(TransactionMapper transactionMapper, CategoryMapper categoryMapper) {
+ public StatisticsServiceImpl(TransactionMapper transactionMapper, CategoryMapper categoryMapper,
+ ChannelService channelService) {
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
+ this.channelService = channelService;
}
/**
@@ -201,6 +205,10 @@ public class StatisticsServiceImpl implements StatisticsService {
}
}
+ // 渠道维度需加载自定义渠道名称映射,用于展示用户自定义渠道名
+ Map channelNames = dimension == StatisticsDimension.CHANNEL
+ ? channelService.nameMap(userId) : Map.of();
+
List result = new ArrayList<>();
BigDecimal grandTotal = BigDecimal.ZERO;
for (Map r : rows) {
@@ -215,7 +223,7 @@ public class StatisticsServiceImpl implements StatisticsService {
vo.setExpense(expense);
vo.setTotal(total);
vo.setCount(toInt(r.get("cnt")));
- fillGroupMeta(vo, dimension, key, categoryMap, income, expense);
+ fillGroupMeta(vo, dimension, key, categoryMap, channelNames, income, expense);
result.add(vo);
grandTotal = grandTotal.add(total);
}
@@ -346,7 +354,8 @@ public class StatisticsServiceImpl implements StatisticsService {
* @param expense 支出金额
*/
private void fillGroupMeta(StatisticsGroupVO vo, StatisticsDimension dimension, String key,
- Map categoryMap, BigDecimal income, BigDecimal expense) {
+ Map categoryMap, Map channelNames,
+ BigDecimal income, BigDecimal expense) {
switch (dimension) {
case CATEGORY -> {
// 未分类记录的分组键无法解析为分类 ID,此时 categoryMap 可能为不可变空 Map,
@@ -358,7 +367,7 @@ public class StatisticsServiceImpl implements StatisticsService {
vo.setType(category != null ? category.getType()
: (income.compareTo(expense) >= 0 ? TransactionType.INCOME.name() : TransactionType.EXPENSE.name()));
}
- case CHANNEL -> vo.setName(channelLabel(key));
+ case CHANNEL -> vo.setName(resolveChannelLabel(key, channelNames));
case TYPE -> {
vo.setName(TransactionType.labelOf(key));
vo.setType(key);
@@ -441,15 +450,20 @@ public class StatisticsServiceImpl implements StatisticsService {
}
/**
- * 将交易渠道编码转换为中文名称
+ * 将交易渠道编码转换为中文名称,优先取用户自定义渠道名称
*
- * @param code 交易渠道编码
+ * @param code 交易渠道编码
+ * @param channelNames 自定义渠道「编码 → 名称」映射
* @return 中文名称
*/
- private String channelLabel(String code) {
+ private String resolveChannelLabel(String code, Map channelNames) {
if (!StringUtils.hasText(code)) {
return "未填写";
}
+ String custom = channelNames.get(code);
+ if (custom != null) {
+ return custom;
+ }
// 兼容历史 BANK 渠道
if ("BANK".equals(code)) return "银行卡";
return ChannelType.labelOf(code);
diff --git a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java
index 57a42c3..e3dfc04 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/TransactionServiceImpl.java
@@ -4,6 +4,8 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -21,20 +23,27 @@ import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.common.PageResult;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.BatchCategoryRequest;
+import cn.rosercode.finance.dto.BatchCreateTransactionRequest;
import cn.rosercode.finance.dto.BatchTagRequest;
import cn.rosercode.finance.dto.TransactionQuery;
import cn.rosercode.finance.dto.TransactionRequest;
+import cn.rosercode.finance.entity.AiImage;
+import cn.rosercode.finance.entity.AiImageTransaction;
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.ChannelType;
import cn.rosercode.finance.enums.TransactionType;
+import cn.rosercode.finance.mapper.AiImageMapper;
+import cn.rosercode.finance.mapper.AiImageTransactionMapper;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.mapper.TransactionMapper;
import cn.rosercode.finance.mapper.TransactionTagMapper;
+import cn.rosercode.finance.service.ChannelService;
import cn.rosercode.finance.service.TransactionService;
+import cn.rosercode.finance.util.TransactionDedupUtils;
import cn.rosercode.finance.vo.TagVO;
import cn.rosercode.finance.vo.TransactionExportVO;
import cn.rosercode.finance.vo.TransactionVO;
@@ -53,17 +62,26 @@ public class TransactionServiceImpl implements TransactionService {
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
+ private final AiImageMapper aiImageMapper;
+ private final AiImageTransactionMapper aiImageTransactionMapper;
+ private final ChannelService channelService;
private final VoConverter voConverter;
public TransactionServiceImpl(TransactionMapper transactionMapper,
CategoryMapper categoryMapper,
TagMapper tagMapper,
TransactionTagMapper transactionTagMapper,
+ AiImageMapper aiImageMapper,
+ AiImageTransactionMapper aiImageTransactionMapper,
+ ChannelService channelService,
VoConverter voConverter) {
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
+ this.aiImageMapper = aiImageMapper;
+ this.aiImageTransactionMapper = aiImageTransactionMapper;
+ this.channelService = channelService;
this.voConverter = voConverter;
}
@@ -173,8 +191,9 @@ public class TransactionServiceImpl implements TransactionService {
List voList = result.getRecords().stream()
.map(t -> voConverter.toTransactionVO(t, finalCategoryMap.get(t.getCategoryId())))
.collect(Collectors.toList());
- // 批量填充标签
+ // 批量填充标签与来源图片
fillTags(voList);
+ fillImages(voList);
return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize());
}
@@ -205,8 +224,117 @@ public class TransactionServiceImpl implements TransactionService {
transactionMapper.insert(transaction);
// 同步标签关联
saveTransactionTags(userId, transaction.getId(), request.getTagIds());
- log.info("新增交易记录: userId={}, id={}, type={}, amount={}",
- userId, transaction.getId(), transaction.getType(), transaction.getAmount());
+ // 金额属敏感信息,日志仅记录类型,不记录金额
+ log.info("新增交易记录: userId={}, id={}, type={}", userId, transaction.getId(), transaction.getType());
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * 逐条校验分类归属并插入记录,可同时建立与 AI 记账图片的关联。
+ */
+ @Override
+ @Transactional
+ public int batchCreate(Long userId, BatchCreateTransactionRequest request) {
+ List records = request.getRecords();
+ if (records == null || records.isEmpty()) {
+ throw new BusinessException("没有可导入的记录");
+ }
+ if (records.size() > 200) {
+ throw new BusinessException("单次最多导入200条记录");
+ }
+
+ // 解析每条记录关联的来源图片:优先取记录自身 imageId,缺省回退顶层 imageId
+ List recordImageIds = new ArrayList<>(records.size());
+ Set imageIdsToCheck = new LinkedHashSet<>();
+ for (TransactionRequest record : records) {
+ Long imageId = record.getImageId() != null ? record.getImageId() : request.getImageId();
+ recordImageIds.add(imageId);
+ if (imageId != null) {
+ imageIdsToCheck.add(imageId);
+ }
+ }
+ // 图片需归属当前用户
+ for (Long imageId : imageIdsToCheck) {
+ AiImage image = aiImageMapper.selectById(imageId);
+ if (image == null || !image.getUserId().equals(userId)) {
+ throw new BusinessException("图片不存在");
+ }
+ }
+
+ // 批量查询已有记录的去重键,用于导入阶段跳过重复(类型+金额+交易时间)
+ Set times = new LinkedHashSet<>();
+ for (TransactionRequest record : records) {
+ if (record.getTransactionTime() != null) {
+ times.add(record.getTransactionTime());
+ }
+ }
+ Set existingKeys = new HashSet<>();
+ if (!times.isEmpty()) {
+ List existing = transactionMapper.selectList(new LambdaQueryWrapper()
+ .eq(Transaction::getUserId, userId)
+ .in(Transaction::getTransactionTime, times));
+ for (Transaction transaction : existing) {
+ String key = TransactionDedupUtils.key(transaction.getType(), transaction.getAmount(),
+ transaction.getTransactionTime());
+ if (key != null) {
+ existingKeys.add(key);
+ }
+ }
+ }
+
+ List createdIds = new ArrayList<>();
+ List createdImageIds = new ArrayList<>();
+ Set seen = new HashSet<>();
+ int skipped = 0;
+ for (int i = 0; i < records.size(); i++) {
+ TransactionRequest record = records.get(i);
+ // 去重:与已有记录或本批已创建记录的类型、金额、时间一致时跳过
+ String dedupKey = TransactionDedupUtils.key(record.getType(), record.getAmount(),
+ record.getTransactionTime());
+ if (dedupKey != null && (existingKeys.contains(dedupKey) || !seen.add(dedupKey))) {
+ skipped++;
+ continue;
+ }
+ // 分类可选:仅在指定分类时校验归属
+ if (record.getCategoryId() != null) {
+ Category category = categoryMapper.selectById(record.getCategoryId());
+ if (category == null || !category.getUserId().equals(userId)) {
+ throw new BusinessException("分类不存在");
+ }
+ }
+ Transaction transaction = new Transaction();
+ transaction.setUserId(userId);
+ transaction.setCategoryId(record.getCategoryId());
+ transaction.setType(record.getType());
+ transaction.setAmount(record.getAmount());
+ transaction.setChannel(record.getChannel());
+ transaction.setRemark(record.getRemark());
+ transaction.setTransactionTime(record.getTransactionTime());
+ transaction.setIncludeInStats(record.getIncludeInStats() == null ? Boolean.TRUE
+ : record.getIncludeInStats());
+ transaction.setRawData(record.getRawData());
+ transactionMapper.insert(transaction);
+ saveTransactionTags(userId, transaction.getId(), record.getTagIds());
+ createdIds.add(transaction.getId());
+ createdImageIds.add(recordImageIds.get(i));
+ }
+
+ // 按每条记录的来源图片建立图片关联
+ for (int i = 0; i < createdIds.size(); i++) {
+ Long imageId = createdImageIds.get(i);
+ if (imageId == null) {
+ continue;
+ }
+ AiImageTransaction relation = new AiImageTransaction();
+ relation.setImageId(imageId);
+ relation.setTransactionId(createdIds.get(i));
+ aiImageTransactionMapper.insert(relation);
+ }
+
+ log.info("批量新增交易记录: userId={}, 请求={}, 创建={}, 跳过重复={}, 图片={}",
+ userId, records.size(), createdIds.size(), skipped, imageIdsToCheck.size());
+ return createdIds.size();
}
/**
@@ -526,6 +654,32 @@ public class TransactionServiceImpl implements TransactionService {
}
}
+ /**
+ * 批量填充交易视图对象的来源图片ID列表
+ *
+ * @param voList 交易视图对象列表
+ */
+ private void fillImages(List voList) {
+ if (voList.isEmpty()) {
+ return;
+ }
+ List transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
+ List relations = aiImageTransactionMapper.selectList(
+ new LambdaQueryWrapper()
+ .in(AiImageTransaction::getTransactionId, transactionIds));
+ if (relations.isEmpty()) {
+ return;
+ }
+ Map> imagesByTransaction = new HashMap<>();
+ for (AiImageTransaction relation : relations) {
+ imagesByTransaction.computeIfAbsent(relation.getTransactionId(), key -> new ArrayList<>())
+ .add(relation.getImageId());
+ }
+ for (TransactionVO vo : voList) {
+ vo.setImageIds(imagesByTransaction.getOrDefault(vo.getId(), List.of()));
+ }
+ }
+
/**
* 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。
*
@@ -663,21 +817,23 @@ public class TransactionServiceImpl implements TransactionService {
categoryMap = categories.stream().collect(Collectors.toMap(Category::getId, c -> c));
}
- // 转换为导出视图对象
+ // 转换为导出视图对象,自定义渠道名称按当前用户解析
Map finalCategoryMap = categoryMap;
+ Map channelNames = channelService.nameMap(userId);
return transactions.stream()
- .map(t -> toExportVO(t, finalCategoryMap.get(t.getCategoryId())))
+ .map(t -> toExportVO(t, finalCategoryMap.get(t.getCategoryId()), channelNames))
.collect(Collectors.toList());
}
/**
* 将交易实体和关联分类转换为导出视图对象
*
- * @param t 交易实体
- * @param category 关联的分类实体(可为 null)
+ * @param t 交易实体
+ * @param category 关联的分类实体(可为 null)
+ * @param channelNames 自定义渠道「编码 → 名称」映射
* @return 交易导出视图对象
*/
- private TransactionExportVO toExportVO(Transaction t, Category category) {
+ private TransactionExportVO toExportVO(Transaction t, Category category, Map channelNames) {
TransactionExportVO vo = new TransactionExportVO();
Boolean include = t.getIncludeInStats() == null ? Boolean.TRUE : t.getIncludeInStats();
if (include) {
@@ -687,7 +843,7 @@ public class TransactionServiceImpl implements TransactionService {
}
vo.setCategoryName(category != null ? category.getName() : "");
vo.setAmount(String.format("%.2f", t.getAmount()));
- vo.setChannel(getChannelLabel(t.getChannel()));
+ vo.setChannel(resolveChannelLabel(t.getChannel(), channelNames));
// 备注被标记为隐藏时,导出同样以占位符替代,避免泄露
if (Boolean.TRUE.equals(t.getRemarkHidden())) {
vo.setRemark(REMARK_HIDDEN_PLACEHOLDER);
@@ -702,26 +858,19 @@ public class TransactionServiceImpl implements TransactionService {
}
/**
- * 将交易渠道编码转换为中文标签
+ * 将交易渠道编码转换为中文标签,优先取用户自定义渠道名称
*
- * @param code 交易渠道编码
+ * @param code 交易渠道编码
+ * @param channelNames 自定义渠道「编码 → 名称」映射
* @return 中文标签
*/
- private String getChannelLabel(String code) {
+ private String resolveChannelLabel(String code, Map channelNames) {
if (code == null) return "";
+ String custom = channelNames.get(code);
+ if (custom != null) return custom;
// 兼容历史 BANK 渠道
if ("BANK".equals(code)) return "银行卡";
return ChannelType.labelOf(code);
}
- /**
- * {@inheritDoc}
- *
- * 由账单来源枚举(支付宝、微信、美团、招商银行、交通银行)与人工记账交易渠道
- * (现金)组成,账单来源在前,人工交易渠道在后。
- */
- @Override
- public List listChannels() {
- return ChannelType.codes();
- }
}
diff --git a/src/main/java/cn/rosercode/finance/util/AppHome.java b/src/main/java/cn/rosercode/finance/util/AppHome.java
new file mode 100644
index 0000000..f8b4678
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/util/AppHome.java
@@ -0,0 +1,51 @@
+package cn.rosercode.finance.util;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * 应用运行目录解析工具。
+ *
+ * 基准目录优先取系统属性 {@code finance.home},其次环境变量 {@code FINANCE_HOME},
+ * 均未设置时回退当前工作目录。新增读写本地文件的功能应经此解析,避免直接使用相对路径。
+ *
+ * @date 2026-09-21 13:03:41
+ */
+public final class AppHome {
+
+ /** 系统属性名:运行目录 */
+ private static final String HOME_PROPERTY = "finance.home";
+
+ /** 环境变量名:运行目录 */
+ private static final String HOME_ENV = "FINANCE_HOME";
+
+ private AppHome() {
+ }
+
+ /**
+ * 获取应用运行基准目录。
+ *
+ * @return 基准目录
+ */
+ public static Path getHome() {
+ String home = System.getProperty(HOME_PROPERTY);
+ if (home == null || home.isBlank()) {
+ home = System.getenv(HOME_ENV);
+ }
+ if (home == null || home.isBlank()) {
+ home = System.getProperty("user.dir");
+ }
+ return Paths.get(home);
+ }
+
+ /**
+ * 基于基准目录解析路径。
+ *
+ * @param first 第一段路径
+ * @param more 其余路径段
+ * @return 解析后的路径
+ */
+ public static Path resolve(String first, String... more) {
+ return getHome().resolve(Paths.get(first, more));
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/util/ImageUtils.java b/src/main/java/cn/rosercode/finance/util/ImageUtils.java
new file mode 100644
index 0000000..7c778b8
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/util/ImageUtils.java
@@ -0,0 +1,175 @@
+package cn.rosercode.finance.util;
+
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import javax.imageio.ImageIO;
+
+/**
+ * 图片工具类,提供基于内容魔数的格式识别与图片尺寸读取。
+ *
+ * 不信任文件名与声明的 MIME 类型,仅依据文件内容判断格式;
+ * 支持 DeepSeek 视觉接口可识别的 JPEG、PNG、GIF、WebP 四种格式。
+ *
+ * @date 2026-09-21 13:03:48
+ */
+public final class ImageUtils {
+
+ private ImageUtils() {
+ }
+
+ /**
+ * 图片格式枚举,维护 MIME 类型与文件扩展名。
+ */
+ public enum ImageType {
+
+ /** JPEG */
+ JPEG("image/jpeg", "jpg"),
+
+ /** PNG */
+ PNG("image/png", "png"),
+
+ /** GIF */
+ GIF("image/gif", "gif"),
+
+ /** WebP */
+ WEBP("image/webp", "webp");
+
+ /** MIME 类型 */
+ private final String mimeType;
+
+ /** 文件扩展名(不含点) */
+ private final String extension;
+
+ ImageType(String mimeType, String extension) {
+ this.mimeType = mimeType;
+ this.extension = extension;
+ }
+
+ /**
+ * 获取 MIME 类型。
+ *
+ * @return MIME 类型
+ */
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ /**
+ * 获取文件扩展名。
+ *
+ * @return 扩展名(不含点)
+ */
+ public String getExtension() {
+ return extension;
+ }
+ }
+
+ /**
+ * 根据文件内容魔数识别图片格式。
+ *
+ * @param content 文件字节
+ * @return 识别到的格式,无法识别时返回 null
+ */
+ public static ImageType detectType(byte[] content) {
+ if (content == null || content.length < 12) {
+ return null;
+ }
+ // JPEG: FF D8 FF
+ if ((content[0] & 0xFF) == 0xFF && (content[1] & 0xFF) == 0xD8 && (content[2] & 0xFF) == 0xFF) {
+ return ImageType.JPEG;
+ }
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
+ if ((content[0] & 0xFF) == 0x89 && (content[1] & 0xFF) == 0x50
+ && (content[2] & 0xFF) == 0x4E && (content[3] & 0xFF) == 0x47
+ && (content[4] & 0xFF) == 0x0D && (content[5] & 0xFF) == 0x0A
+ && (content[6] & 0xFF) == 0x1A && (content[7] & 0xFF) == 0x0A) {
+ return ImageType.PNG;
+ }
+ // GIF: 47 49 46 38
+ if ((content[0] & 0xFF) == 0x47 && (content[1] & 0xFF) == 0x49
+ && (content[2] & 0xFF) == 0x46 && (content[3] & 0xFF) == 0x38) {
+ return ImageType.GIF;
+ }
+ // WebP: 52 49 46 46 xx xx xx xx 57 45 42 50
+ if ((content[0] & 0xFF) == 0x52 && (content[1] & 0xFF) == 0x49
+ && (content[2] & 0xFF) == 0x46 && (content[3] & 0xFF) == 0x46
+ && (content[8] & 0xFF) == 0x57 && (content[9] & 0xFF) == 0x45
+ && (content[10] & 0xFF) == 0x42 && (content[11] & 0xFF) == 0x50) {
+ return ImageType.WEBP;
+ }
+ return null;
+ }
+
+ /**
+ * 生成缩略图字节(统一输出 JPEG)。
+ *
+ * 按最长边等比缩放到 {@code maxSize} 以内,透明区域填充白色以避免 JPEG 黑底;
+ * 图片本身不超过目标尺寸时也会重新编码,便于调用方统一使用 JPEG 响应。
+ *
+ * @param content 原图字节
+ * @param maxSize 缩略图最长边(像素)
+ * @return 缩略图 JPEG 字节,无法解码时返回 null
+ */
+ public static byte[] thumbnail(byte[] content, int maxSize) {
+ if (content == null || content.length == 0 || maxSize <= 0) {
+ return null;
+ }
+ try (ByteArrayInputStream in = new ByteArrayInputStream(content)) {
+ BufferedImage source = ImageIO.read(in);
+ if (source == null) {
+ return null;
+ }
+ int width = source.getWidth();
+ int height = source.getHeight();
+ int longest = Math.max(width, height);
+ double ratio = longest > maxSize ? (double) maxSize / longest : 1d;
+ int targetWidth = Math.max(1, (int) Math.round(width * ratio));
+ int targetHeight = Math.max(1, (int) Math.round(height * ratio));
+ BufferedImage thumb = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
+ Graphics2D graphics = thumb.createGraphics();
+ try {
+ graphics.setColor(Color.WHITE);
+ graphics.fillRect(0, 0, targetWidth, targetHeight);
+ graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+ graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+ graphics.drawImage(source, 0, 0, targetWidth, targetHeight, null);
+ } finally {
+ graphics.dispose();
+ }
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ if (!ImageIO.write(thumb, "jpg", out)) {
+ return null;
+ }
+ return out.toByteArray();
+ }
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * 读取图片尺寸。
+ *
+ * @param content 文件字节
+ * @return 尺寸数组 [宽, 高],无法读取时返回 null
+ */
+ public static int[] readSize(byte[] content) {
+ if (content == null || content.length == 0) {
+ return null;
+ }
+ try (ByteArrayInputStream in = new ByteArrayInputStream(content)) {
+ BufferedImage image = ImageIO.read(in);
+ if (image == null) {
+ return null;
+ }
+ return new int[] {image.getWidth(), image.getHeight()};
+ } catch (IOException e) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/util/TransactionDedupUtils.java b/src/main/java/cn/rosercode/finance/util/TransactionDedupUtils.java
new file mode 100644
index 0000000..85d4cc2
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/util/TransactionDedupUtils.java
@@ -0,0 +1,53 @@
+package cn.rosercode.finance.util;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 交易记录去重键工具。
+ *
+ * 图片记账等无外部交易号的场景,使用「收支类型 + 金额 + 交易时间」作为业务去重键,
+ * 用于判断是否与已有记录(或同批识别结果)重复。金额统一去除末尾零、时间统一到秒,
+ * 保证同一笔记录生成相同的键。
+ *
+ * @date 2026-09-21 16:50:00
+ */
+public final class TransactionDedupUtils {
+
+ private TransactionDedupUtils() {
+ }
+
+ /**
+ * 生成去重键。
+ *
+ * @param type 收支类型
+ * @param amount 金额
+ * @param time 交易时间
+ * @return 去重键,任一参数为空时返回 null
+ */
+ public static String key(String type, BigDecimal amount, LocalDateTime time) {
+ if (type == null || amount == null || time == null) {
+ return null;
+ }
+ return type + "|" + amount.stripTrailingZeros().toPlainString() + "|" + time.withNano(0);
+ }
+
+ /**
+ * 生成去重键(时间字符串形式)。
+ *
+ * @param type 收支类型
+ * @param amount 金额
+ * @param time 交易时间字符串(yyyy-MM-dd'T'HH:mm:ss)
+ * @return 去重键,无法解析或任一参数为空时返回 null
+ */
+ public static String key(String type, BigDecimal amount, String time) {
+ if (time == null || time.isBlank()) {
+ return null;
+ }
+ try {
+ return key(type, amount, LocalDateTime.parse(time.trim()));
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/vo/AiChannelResultVO.java b/src/main/java/cn/rosercode/finance/vo/AiChannelResultVO.java
new file mode 100644
index 0000000..814e216
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/vo/AiChannelResultVO.java
@@ -0,0 +1,18 @@
+package cn.rosercode.finance.vo;
+
+import lombok.Data;
+
+/**
+ * AI 生成交易渠道结果视图对象,字段与渠道表单对应。
+ *
+ * @date 2026-09-21 15:59:32
+ */
+@Data
+public class AiChannelResultVO {
+
+ /** 建议的渠道名称 */
+ private String name;
+
+ /** 生成理由,用于向用户说明 */
+ private String reason;
+}
diff --git a/src/main/java/cn/rosercode/finance/vo/AiConfigVO.java b/src/main/java/cn/rosercode/finance/vo/AiConfigVO.java
index 461a4ab..57e92a6 100644
--- a/src/main/java/cn/rosercode/finance/vo/AiConfigVO.java
+++ b/src/main/java/cn/rosercode/finance/vo/AiConfigVO.java
@@ -24,4 +24,7 @@ public class AiConfigVO {
/** 使用的模型名称 */
private String model;
+
+ /** 图片记账使用的视觉模型名称 */
+ private String visionModel;
}
diff --git a/src/main/java/cn/rosercode/finance/vo/AiRecordImageResultVO.java b/src/main/java/cn/rosercode/finance/vo/AiRecordImageResultVO.java
new file mode 100644
index 0000000..9f860d4
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/vo/AiRecordImageResultVO.java
@@ -0,0 +1,26 @@
+package cn.rosercode.finance.vo;
+
+import java.util.List;
+
+import lombok.Data;
+
+/**
+ * AI 图片记账解析结果视图对象,包含图片ID与解析出的多条交易记录。
+ *
+ * @date 2026-09-21 13:20:06
+ */
+@Data
+public class AiRecordImageResultVO {
+
+ /** 保存的图片ID列表,按上传顺序排列 */
+ private List imageIds;
+
+ /** 解析摘要 */
+ private String summary;
+
+ /** 解析出的交易记录列表 */
+ private List records;
+
+ /** 未能识别的关键词 */
+ private List unmatched;
+}
diff --git a/src/main/java/cn/rosercode/finance/vo/AiRecordResultVO.java b/src/main/java/cn/rosercode/finance/vo/AiRecordResultVO.java
index 4fd0e29..4305626 100644
--- a/src/main/java/cn/rosercode/finance/vo/AiRecordResultVO.java
+++ b/src/main/java/cn/rosercode/finance/vo/AiRecordResultVO.java
@@ -48,4 +48,16 @@ public class AiRecordResultVO {
/** 未能识别的关键词 */
private List unmatched;
+
+ /** 原始数据(图片记账时由模型提取的原始字段 JSON 文本),文本记账为空 */
+ private String rawData;
+
+ /** 来源图片序号(图片记账时模型标注,从 1 开始),文本记账为空 */
+ private Integer imageIndex;
+
+ /** 来源图片ID(后端按 imageIndex 映射),文本记账为空 */
+ private Long imageId;
+
+ /** 是否疑似重复:与已有记录或本批其它记录的类型、金额、时间一致 */
+ private Boolean duplicate;
}
diff --git a/src/main/java/cn/rosercode/finance/vo/AiSuggestionVO.java b/src/main/java/cn/rosercode/finance/vo/AiSuggestionVO.java
index da0a54c..e1e6a76 100644
--- a/src/main/java/cn/rosercode/finance/vo/AiSuggestionVO.java
+++ b/src/main/java/cn/rosercode/finance/vo/AiSuggestionVO.java
@@ -33,6 +33,12 @@ public class AiSuggestionVO {
/** 建议标签名称列表 */
private List tagNames;
+ /** 建议交易渠道编码,为 null 表示无渠道建议 */
+ private String channel;
+
+ /** 建议交易渠道名称(自定义渠道名称,内置渠道为空) */
+ private String channelName;
+
/** 建议新建的分类名称,为 null 表示不新建分类 */
private String newCategoryName;
diff --git a/src/main/java/cn/rosercode/finance/vo/ChannelOptionVO.java b/src/main/java/cn/rosercode/finance/vo/ChannelOptionVO.java
new file mode 100644
index 0000000..c6aa782
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/vo/ChannelOptionVO.java
@@ -0,0 +1,50 @@
+package cn.rosercode.finance.vo;
+
+import lombok.Data;
+
+/**
+ * 交易渠道选项视图对象,供表单与筛选下拉使用,包含内置与用户自定义渠道。
+ *
+ * @date 2026-09-21 14:10:04
+ */
+@Data
+public class ChannelOptionVO {
+
+ /** 渠道编码 */
+ private String code;
+
+ /** 渠道名称,内置渠道为空(由前端按语言渲染) */
+ private String name;
+
+ /** 是否为内置渠道 */
+ private Boolean builtin;
+
+ /**
+ * 构造内置渠道选项。
+ *
+ * @param code 渠道编码
+ * @return 渠道选项
+ */
+ public static ChannelOptionVO builtin(String code) {
+ ChannelOptionVO vo = new ChannelOptionVO();
+ vo.setCode(code);
+ vo.setName(null);
+ vo.setBuiltin(Boolean.TRUE);
+ return vo;
+ }
+
+ /**
+ * 构造自定义渠道选项。
+ *
+ * @param code 渠道编码
+ * @param name 渠道名称
+ * @return 渠道选项
+ */
+ public static ChannelOptionVO custom(String code, String name) {
+ ChannelOptionVO vo = new ChannelOptionVO();
+ vo.setCode(code);
+ vo.setName(name);
+ vo.setBuiltin(Boolean.FALSE);
+ return vo;
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/vo/ChannelVO.java b/src/main/java/cn/rosercode/finance/vo/ChannelVO.java
new file mode 100644
index 0000000..438af04
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/vo/ChannelVO.java
@@ -0,0 +1,24 @@
+package cn.rosercode.finance.vo;
+
+import lombok.Data;
+
+/**
+ * 自定义交易渠道视图对象,用于渠道管理列表。
+ *
+ * @date 2026-09-21 14:10:03
+ */
+@Data
+public class ChannelVO {
+
+ /** 渠道ID */
+ private Long id;
+
+ /** 渠道编码 */
+ private String code;
+
+ /** 渠道名称 */
+ private String name;
+
+ /** 使用该渠道的账单数量,用于判断能否删除 */
+ private Long transactionCount;
+}
diff --git a/src/main/java/cn/rosercode/finance/vo/TransactionVO.java b/src/main/java/cn/rosercode/finance/vo/TransactionVO.java
index ff68db7..8517284 100644
--- a/src/main/java/cn/rosercode/finance/vo/TransactionVO.java
+++ b/src/main/java/cn/rosercode/finance/vo/TransactionVO.java
@@ -53,4 +53,7 @@ public class TransactionVO {
/** 标签列表 */
private List tags;
+
+ /** 来源图片ID列表(图片记账生成时存在,手动记账为空) */
+ private List imageIds;
}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index e118f83..c711c7a 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -4,7 +4,8 @@ spring:
servlet:
multipart:
max-file-size: 10MB
- max-request-size: 10MB
+ # 图片记账支持一次上传多张图片,请求体上限相应放宽
+ max-request-size: 50MB
# AI 提示词模板:TEXT 模式加载 classpath:/prompts/*.tpl,
# 由 cn.rosercode.finance.prompt.PromptTemplateService 统一渲染
thymeleaf:
diff --git a/src/main/resources/prompts/ai-channel-system.tpl b/src/main/resources/prompts/ai-channel-system.tpl
new file mode 100644
index 0000000..e837738
--- /dev/null
+++ b/src/main/resources/prompts/ai-channel-system.tpl
@@ -0,0 +1,3 @@
+你是一个个人记账应用的交易渠道助手。请根据用户对交易渠道用途的自然语言描述,生成一个简洁的记账渠道名称。要求:
+1. name 为 2-6 个汉字的渠道名称,通常是支付平台、银行或支付方式(如「招商银行」「云闪付」);
+2. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"招商银行","reason":"简要中文理由"}
diff --git a/src/main/resources/prompts/ai-record-image-system.tpl b/src/main/resources/prompts/ai-record-image-system.tpl
new file mode 100644
index 0000000..173aa4c
--- /dev/null
+++ b/src/main/resources/prompts/ai-record-image-system.tpl
@@ -0,0 +1,20 @@
+你是一个记账助手。用户可能上传一张或多张账单或支付截图,图片按上传顺序从 1 开始编号。请识别所有图片中的每一笔交易,解析为结构化记录,只输出 JSON,不要输出任何多余内容。
+可用的收支类型:[(${typeDesc})]。
+可用的交易渠道:[(${channelDesc})]。
+可用分类:[(${categoryNames})]。
+可用标签:[(${tagNames})]。
+规则:
+1. 逐笔识别所有图片中的交易,按交易时间顺序输出到 records 数组;
+2. 每条记录必须输出 imageIndex,表示该笔交易来自第几张图片(从 1 开始);
+3. type 默认为 EXPENSE,除非明显是收入;
+4. amount 为必填数字;
+5. categoryNames 从可用分类中选择最合适的一个(数组,最多一个),无法判断则为空数组;
+6. tagNames 从可用标签中选择(数组,可为空);
+7. channel 从可用交易渠道中选择,无法判断为 null;
+8. transactionTime 输出 yyyy-MM-ddTHH:mm:ss,未提及日期时用当前日期,仅有月日时补全年份,相对时间(如昨天、上周五)请换算;
+9. includeInStats 默认 true;
+10. remark 给出简洁备注,可包含交易对方或商品名称的概括;
+11. 所有图片中都没有可识别的交易时,records 为空数组;
+12. rawData 为该笔交易在图片中出现的全部可见字段组成的对象,键使用图片中的原始列名(如「交易时间」「交易对方」「商品说明」「收/支」「金额」「支付方式」「交易状态」「交易订单号」「商户单号」「备注」等),值为对应文本;尽可能完整提取,图片中不存在的字段省略。
+当前时间:[(${now})]。
+输出格式:{"summary":"简要说明识别到的账单内容","records":[{"imageIndex":1,"type":"EXPENSE","amount":35.5,"categoryNames":["餐饮"],"tagNames":[],"channel":"ALIPAY","transactionTime":"[(${exampleTime})]","remark":"午餐","includeInStats":true,"rawData":{"交易时间":"2026-09-20 12:30:00","交易对方":"味香园餐厅","商品说明":"午餐","收/支":"支出","金额":"-35.50","支付方式":"支付宝","交易状态":"交易成功","交易订单号":"2026092022001234567890"}}],"unmatched":[]}
diff --git a/src/main/resources/prompts/ai-suggestion-strict-system.tpl b/src/main/resources/prompts/ai-suggestion-strict-system.tpl
index 73f7c7e..5c5244b 100644
--- a/src/main/resources/prompts/ai-suggestion-strict-system.tpl
+++ b/src/main/resources/prompts/ai-suggestion-strict-system.tpl
@@ -2,4 +2,5 @@
1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);
2. 只能使用给定列表中的 ID,不要编造;
3. 标签可多选,也可为空数组;
-4. 只输出 JSON,不要输出任何多余内容。输出格式:{"suggestions":[{"id":记录ID,"categoryId":分类ID,"tagIds":[标签ID],"reason":"简要中文理由"}]}
\ No newline at end of file
+4. 若输入包含 channels 候选列表,请结合记录的类型、备注、交易渠道等信息选择最合适的渠道,channel 只能取候选列表中的 code,无法判断时返回 null;
+5. 只输出 JSON,不要输出任何多余内容。输出格式:{"suggestions":[{"id":记录ID,"categoryId":分类ID,"tagIds":[标签ID],"channel":"渠道编码或null","reason":"简要中文理由"}]}
\ No newline at end of file
diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql
index a26f4bf..e80ab6d 100644
--- a/src/main/resources/sql/init.sql
+++ b/src/main/resources/sql/init.sql
@@ -76,6 +76,7 @@ CREATE TABLE IF NOT EXISTS t_ai_config (
api_key VARCHAR(255) COMMENT 'DeepSeek API Key(明文存储)',
base_url VARCHAR(255) NOT NULL DEFAULT 'https://api.deepseek.com' COMMENT 'API 基础地址',
model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat' COMMENT '使用的模型名称',
+ vision_model VARCHAR(50) NOT NULL DEFAULT 'deepseek-flash' COMMENT '图片记账使用的视觉模型名称',
UNIQUE INDEX uk_user_id (user_id) COMMENT '用户ID唯一索引,每个用户一条配置'
);
@@ -97,6 +98,32 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);
+-- AI 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要
+CREATE TABLE IF NOT EXISTS t_ai_image (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '图片ID,主键自增',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ user_id BIGINT NOT NULL COMMENT '所属用户ID',
+ file_path VARCHAR(500) NOT NULL COMMENT '图片本地存储相对路径',
+ mime_type VARCHAR(50) COMMENT '图片 MIME 类型,如 image/jpeg',
+ file_size BIGINT NOT NULL DEFAULT 0 COMMENT '图片文件大小(字节)',
+ width INT COMMENT '图片宽度(像素)',
+ height INT COMMENT '图片高度(像素)',
+ model VARCHAR(50) COMMENT '解析使用的视觉模型名称',
+ summary VARCHAR(500) COMMENT '解析结果摘要',
+ record_count INT NOT NULL DEFAULT 0 COMMENT '解析出的交易记录条数',
+ INDEX idx_ai_image_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
+);
+
+-- AI 记账图片与交易记录关联表:记录每张图片解析生成的交易记录
+CREATE TABLE IF NOT EXISTS t_ai_image_transaction (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ image_id BIGINT NOT NULL COMMENT 'AI 记账图片ID',
+ transaction_id BIGINT NOT NULL COMMENT '交易记录ID',
+ UNIQUE INDEX uk_ai_image_transaction (image_id, transaction_id) COMMENT '图片+交易唯一索引',
+ INDEX idx_ai_image_transaction_transaction_id (transaction_id) COMMENT '交易记录ID索引'
+);
+
-- 通知配置表:存储每个用户的账单导入提醒设置
CREATE TABLE IF NOT EXISTS t_notification_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增',
@@ -271,3 +298,17 @@ CREATE TABLE IF NOT EXISTS t_user_preference (
pref_value TEXT COMMENT '偏好值,标量字符串或 JSON',
UNIQUE INDEX uk_user_pref (user_id, pref_key) COMMENT '用户+偏好键唯一索引'
);
+
+-- 用户自定义交易渠道表:仅存放用户自建渠道,内置渠道由枚举维护
+CREATE TABLE IF NOT EXISTS t_channel (
+ 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',
+ code VARCHAR(30) NOT NULL COMMENT '渠道编码,后端生成且稳定不变,交易记录按此关联',
+ name VARCHAR(50) NOT NULL COMMENT '渠道名称,用户可修改',
+ sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,数值越小越靠前',
+ UNIQUE INDEX uk_channel_user_code (user_id, code) COMMENT '用户+渠道编码唯一索引',
+ UNIQUE INDEX uk_channel_user_name (user_id, name) COMMENT '用户+渠道名称唯一索引',
+ INDEX idx_channel_user_id (user_id) COMMENT '用户ID索引'
+);
diff --git a/src/main/resources/sql/schema-sqlite.sql b/src/main/resources/sql/schema-sqlite.sql
index 336e919..ff9e166 100644
--- a/src/main/resources/sql/schema-sqlite.sql
+++ b/src/main/resources/sql/schema-sqlite.sql
@@ -78,7 +78,8 @@ CREATE TABLE IF NOT EXISTS t_ai_config (
enabled INTEGER NOT NULL DEFAULT 0,
api_key VARCHAR(255),
base_url VARCHAR(255) NOT NULL DEFAULT 'https://api.deepseek.com',
- model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat'
+ model VARCHAR(50) NOT NULL DEFAULT 'deepseek-chat',
+ vision_model VARCHAR(50) NOT NULL DEFAULT 'deepseek-flash'
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_ai_config_user_id ON t_ai_config (user_id);;
@@ -99,6 +100,32 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
);;
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_created ON t_ai_usage (user_id, created_at);;
+-- AI 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要
+CREATE TABLE IF NOT EXISTS t_ai_image (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
+ user_id INTEGER NOT NULL,
+ file_path VARCHAR(500) NOT NULL,
+ mime_type VARCHAR(50),
+ file_size INTEGER NOT NULL DEFAULT 0,
+ width INTEGER,
+ height INTEGER,
+ model VARCHAR(50),
+ summary VARCHAR(500),
+ record_count INTEGER NOT NULL DEFAULT 0
+);;
+CREATE INDEX IF NOT EXISTS idx_ai_image_user_created ON t_ai_image (user_id, created_at);;
+
+-- AI 记账图片与交易记录关联表:记录每张图片解析生成的交易记录
+CREATE TABLE IF NOT EXISTS t_ai_image_transaction (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
+ image_id INTEGER NOT NULL,
+ transaction_id INTEGER NOT NULL
+);;
+CREATE UNIQUE INDEX IF NOT EXISTS uk_ai_image_transaction ON t_ai_image_transaction (image_id, transaction_id);;
+CREATE INDEX IF NOT EXISTS idx_ai_image_transaction_transaction_id ON t_ai_image_transaction (transaction_id);;
+
-- 兜底触发器:当应用层未显式更新 updated_at 时,自动刷新为本地时间
CREATE TRIGGER IF NOT EXISTS trg_user_updated_at
AFTER UPDATE ON t_user
@@ -343,3 +370,25 @@ 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;;
+
+-- 用户自定义交易渠道表:仅存放用户自建渠道,内置渠道由枚举维护
+CREATE TABLE IF NOT EXISTS t_channel (
+ 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,
+ code VARCHAR(30) NOT NULL,
+ name VARCHAR(50) NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0
+);;
+CREATE UNIQUE INDEX IF NOT EXISTS uk_channel_user_code ON t_channel (user_id, code);;
+CREATE UNIQUE INDEX IF NOT EXISTS uk_channel_user_name ON t_channel (user_id, name);;
+CREATE INDEX IF NOT EXISTS idx_channel_user_id ON t_channel (user_id);;
+
+CREATE TRIGGER IF NOT EXISTS trg_channel_updated_at
+AFTER UPDATE ON t_channel
+FOR EACH ROW
+WHEN NEW.updated_at = OLD.updated_at
+BEGIN
+ UPDATE t_channel SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
+END;;