diff --git a/build.gradle b/build.gradle
index 80f9c00..b88d4d5 100644
--- a/build.gradle
+++ b/build.gradle
@@ -28,6 +28,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-mail'
+ // Thymeleaf(TEXT 模式):加载 classpath:/prompts 下的 AI 提示词模板
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.security:spring-security-crypto'
implementation 'com.baomidou:mybatis-plus-spring-boot3-starter:3.5.12'
diff --git a/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java b/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java
new file mode 100644
index 0000000..fc3b289
--- /dev/null
+++ b/src/main/java/cn/rosercode/finance/prompt/PromptTemplateService.java
@@ -0,0 +1,202 @@
+package cn.rosercode.finance.prompt;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import org.springframework.stereotype.Component;
+import org.thymeleaf.TemplateEngine;
+import org.thymeleaf.context.Context;
+
+import cn.rosercode.finance.common.BusinessException;
+
+/**
+ * AI 提示词模板渲染组件。
+ *
+ *
统一从 {@code classpath:/prompts/} 加载 Thymeleaf 文本模板(TEXT 模式)。
+ * 每个模板对应一个语义化方法,调用方只需传入该模板所需数据、无需关心模板名,
+ * 使提示词与 Java 代码解耦、便于集中维护。模板解析规则见
+ * {@code application.yml} 的 {@code spring.thymeleaf} 配置。
+ *
+ * @date 2026-09-21 10:58:05
+ */
+@Component
+public class PromptTemplateService {
+
+ /** Thymeleaf 模板引擎,模板前缀、后缀与模式由配置决定 */
+ private final TemplateEngine templateEngine;
+
+ /**
+ * 构造方法,注入模板引擎。
+ *
+ * @param templateEngine Thymeleaf 模板引擎
+ */
+ public PromptTemplateService(TemplateEngine templateEngine) {
+ this.templateEngine = templateEngine;
+ }
+
+ /**
+ * 渲染自然语言记账的系统提示词(模板 {@code ai-record-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 recordSystemPrompt(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-system", variables);
+ }
+
+ /**
+ * 渲染自然语言查询的系统提示词(模板 {@code ai-query-system})。
+ *
+ * @param typeDesc 收支类型说明
+ * @param channelDesc 交易渠道说明
+ * @param categoryNames 可用分类名称
+ * @param tagNames 可用标签名称
+ * @param today 当天日期(yyyy-MM-dd)
+ * @return 系统提示词
+ */
+ public String querySystemPrompt(String typeDesc, String channelDesc, String categoryNames,
+ String tagNames, String today) {
+ Map variables = new LinkedHashMap<>();
+ variables.put("typeDesc", typeDesc);
+ variables.put("channelDesc", channelDesc);
+ variables.put("categoryNames", categoryNames);
+ variables.put("tagNames", tagNames);
+ variables.put("today", today);
+ return render("ai-query-system", variables);
+ }
+
+ /**
+ * 渲染自然语言统计查询的系统提示词(模板 {@code ai-statistics-system})。
+ *
+ * @param typeDesc 收支类型说明
+ * @param channelDesc 交易渠道说明
+ * @param categoryNames 可用分类名称
+ * @param tagNames 可用标签名称
+ * @param today 当天日期(yyyy-MM-dd)
+ * @return 系统提示词
+ */
+ public String statisticsSystemPrompt(String typeDesc, String channelDesc, String categoryNames,
+ String tagNames, String today) {
+ Map variables = new LinkedHashMap<>();
+ variables.put("typeDesc", typeDesc);
+ variables.put("channelDesc", channelDesc);
+ variables.put("categoryNames", categoryNames);
+ variables.put("tagNames", tagNames);
+ variables.put("today", today);
+ return render("ai-statistics-system", variables);
+ }
+
+ /**
+ * 渲染生成分类的系统提示词(模板 {@code ai-category-system})。
+ *
+ * @param typeDesc 收支类型说明
+ * @return 系统提示词
+ */
+ public String categorySystemPrompt(String typeDesc) {
+ return render("ai-category-system", Map.of("typeDesc", typeDesc));
+ }
+
+ /**
+ * 渲染生成标签的系统提示词(模板 {@code ai-tag-system})。
+ *
+ * @return 系统提示词
+ */
+ public String tagSystemPrompt() {
+ return render("ai-tag-system", null);
+ }
+
+ /**
+ * 渲染 Emoji 图标建议的系统提示词(模板 {@code ai-decoration-emoji-system})。
+ *
+ * @return 系统提示词
+ */
+ public String decorationEmojiSystemPrompt() {
+ return render("ai-decoration-emoji-system", null);
+ }
+
+ /**
+ * 渲染配色建议的系统提示词(模板 {@code ai-decoration-color-system})。
+ *
+ * @return 系统提示词
+ */
+ public String decorationColorSystemPrompt() {
+ return render("ai-decoration-color-system", null);
+ }
+
+ /**
+ * 渲染严格模式分类标签建议的系统提示词(模板 {@code ai-suggestion-strict-system})。
+ *
+ * @return 系统提示词
+ */
+ public String suggestionStrictSystemPrompt() {
+ return render("ai-suggestion-strict-system", null);
+ }
+
+ /**
+ * 渲染增强模式分类标签建议的系统提示词(模板 {@code ai-suggestion-new-system})。
+ *
+ * @param scope 建议范围(CATEGORY / TAG / BOTH),决定追加的限定说明
+ * @return 系统提示词
+ */
+ public String suggestionNewSystemPrompt(String scope) {
+ return render("ai-suggestion-new-system", Map.of("scope", scope));
+ }
+
+ /**
+ * 渲染规则生成的系统提示词(模板 {@code ai-rule-system})。
+ *
+ * @return 系统提示词
+ */
+ public String ruleSystemPrompt() {
+ return render("ai-rule-system", null);
+ }
+
+ /**
+ * 渲染连接测试的系统提示词(模板 {@code ai-config-test-system})。
+ *
+ * @return 系统提示词
+ */
+ public String configTestSystemPrompt() {
+ return render("ai-config-test-system", null);
+ }
+
+ /**
+ * 渲染连接测试的用户提示词(模板 {@code ai-config-test-user})。
+ *
+ * @return 用户提示词
+ */
+ public String configTestUserPrompt() {
+ return render("ai-config-test-user", null);
+ }
+
+ /**
+ * 渲染提示词模板,模板名与变量由各专用方法提供。
+ *
+ * @param template 模板名(不含 {@code .tpl} 后缀)
+ * @param variables 模板变量,允许为 null
+ * @return 渲染后的提示词文本
+ */
+ private String render(String template, Map variables) {
+ Context context = new Context(Locale.CHINA, variables == null ? Collections.emptyMap() : variables);
+ try {
+ return templateEngine.process(template, context);
+ } catch (RuntimeException e) {
+ throw new BusinessException("加载提示词模板失败: " + template);
+ }
+ }
+}
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiCategoryServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiCategoryServiceImpl.java
index c8c47a1..06b6308 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiCategoryServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiCategoryServiceImpl.java
@@ -13,6 +13,7 @@ import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.TransactionType;
+import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiCategoryService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiUsageService;
@@ -35,26 +36,22 @@ public class AiCategoryServiceImpl implements AiCategoryService {
/** 合法的收支类型,统一取自类型枚举 */
private static final List VALID_TYPES = TransactionType.codes();
- /** 系统提示词,要求模型严格返回 JSON */
- private static final String SYSTEM_PROMPT = "你是一个个人记账应用的分类助手。"
- + "请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。"
- + "要求:1. name 为 2-6 个汉字的分类名称;2. type 只能是 " + TransactionType.describe() + ";"
- + "3. icon 为与该分类最贴切的一个 Emoji;4. 只输出 JSON,不要输出任何多余内容。"
- + "输出格式:{\"name\":\"交通\",\"type\":\"EXPENSE\",\"icon\":\"🚌\",\"reason\":\"简要中文理由\"}";
-
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
+ private final PromptTemplateService promptTemplateService;
public AiCategoryServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
- ObjectMapper objectMapper) {
+ ObjectMapper objectMapper,
+ PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
+ this.promptTemplateService = promptTemplateService;
}
/**
@@ -73,7 +70,8 @@ public class AiCategoryServiceImpl implements AiCategoryService {
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
- SYSTEM_PROMPT, text.trim());
+ promptTemplateService.categorySystemPrompt(TransactionType.describe()),
+ text.trim());
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.CATEGORY, config.getModel(), 0,
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 441e97c..1ec0af0 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiConfigServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiConfigServiceImpl.java
@@ -12,6 +12,7 @@ import cn.rosercode.finance.dto.AiConfigRequest;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.mapper.AiConfigMapper;
+import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiConfigVO;
@@ -34,12 +35,14 @@ public class AiConfigServiceImpl implements AiConfigService {
private final AiConfigMapper aiConfigMapper;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
+ private final PromptTemplateService promptTemplateService;
public AiConfigServiceImpl(AiConfigMapper aiConfigMapper, DeepSeekClient deepSeekClient,
- AiUsageService aiUsageService) {
+ AiUsageService aiUsageService, PromptTemplateService promptTemplateService) {
this.aiConfigMapper = aiConfigMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
+ this.promptTemplateService = promptTemplateService;
}
/**
@@ -119,8 +122,8 @@ public class AiConfigServiceImpl implements AiConfigService {
DeepSeekResponse response;
try {
response = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
- "你是一个测试助手,请只输出 JSON。",
- "请返回 {\"ok\":true}");
+ promptTemplateService.configTestSystemPrompt(),
+ promptTemplateService.configTestUserPrompt());
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.TEST, config.getModel(), 0,
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiDecorationServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiDecorationServiceImpl.java
index 23e75cf..ddc292f 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiDecorationServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiDecorationServiceImpl.java
@@ -13,6 +13,7 @@ import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.TransactionType;
+import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiDecorationService;
import cn.rosercode.finance.service.AiUsageService;
@@ -25,16 +26,6 @@ import cn.rosercode.finance.service.AiUsageService;
@Service
public class AiDecorationServiceImpl implements AiDecorationService {
- /** Emoji 建议提示词 */
- private static final String EMOJI_SYSTEM_PROMPT = "你是一个个人记账应用的图标助手。"
- + "请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。"
- + "只输出 JSON,不要输出任何多余内容。输出格式:{\"emoji\":\"🍜\"}";
-
- /** 颜色建议提示词 */
- private static final String COLOR_SYSTEM_PROMPT = "你是一个个人记账应用的配色助手。"
- + "请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。"
- + "只输出 JSON,不要输出任何多余内容。输出格式:{\"color\":\"#18a058\"}";
-
/** Emoji 最大长度(按 UTF-16 字符数限制,兼容组合 Emoji) */
private static final int EMOJI_MAX_LENGTH = 16;
@@ -45,15 +36,18 @@ public class AiDecorationServiceImpl implements AiDecorationService {
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
+ private final PromptTemplateService promptTemplateService;
public AiDecorationServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
- ObjectMapper objectMapper) {
+ ObjectMapper objectMapper,
+ PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
+ this.promptTemplateService = promptTemplateService;
}
/**
@@ -66,7 +60,8 @@ public class AiDecorationServiceImpl implements AiDecorationService {
String trimmed = requireName(name);
AiConfig config = requireConfig(userId);
String userPrompt = "分类名称:" + trimmed + ";收支类型:" + typeLabel(type);
- JsonNode root = chatAndParse(userId, config, EMOJI_SYSTEM_PROMPT, userPrompt);
+ JsonNode root = chatAndParse(userId, config,
+ promptTemplateService.decorationEmojiSystemPrompt(), userPrompt);
String emoji = root.path("emoji").asText("").trim();
if (isBlank(emoji) || emoji.length() > EMOJI_MAX_LENGTH) {
throw new BusinessException("AI 未返回有效的 Emoji,请重试");
@@ -84,7 +79,8 @@ public class AiDecorationServiceImpl implements AiDecorationService {
String trimmed = requireName(name);
AiConfig config = requireConfig(userId);
String userPrompt = "标签名称:" + trimmed;
- JsonNode root = chatAndParse(userId, config, COLOR_SYSTEM_PROMPT, userPrompt);
+ JsonNode root = chatAndParse(userId, config,
+ promptTemplateService.decorationColorSystemPrompt(), userPrompt);
String color = root.path("color").asText("").trim();
if (!COLOR_PATTERN.matcher(color).matches()) {
throw new BusinessException("AI 未返回有效的颜色,请重试");
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 586ad19..2ec0c36 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiQueryServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiQueryServiceImpl.java
@@ -25,6 +25,7 @@ import cn.rosercode.finance.enums.ChannelType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
+import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiQueryService;
import cn.rosercode.finance.service.AiUsageService;
@@ -56,19 +57,22 @@ public class AiQueryServiceImpl implements AiQueryService {
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
+ private final PromptTemplateService promptTemplateService;
public AiQueryServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
TagMapper tagMapper,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
- ObjectMapper objectMapper) {
+ ObjectMapper objectMapper,
+ PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
+ this.promptTemplateService = promptTemplateService;
}
/**
@@ -122,19 +126,12 @@ public class AiQueryServiceImpl implements AiQueryService {
private String buildSystemPrompt(List categories, List tags) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
- String today = LocalDate.now().format(DATE_FMT);
- return "你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。\n"
- + "可用的收支类型:" + TransactionType.describe() + "。\n"
- + "可用的交易渠道:" + ChannelType.describe() + "。\n"
- + "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
- + "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
- + "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
- + "LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。\n"
- + "未提及的字段请留空(数组用 [],其余用 null)。分类和标签只能使用上面给出的名称。\n"
- + "今天是 " + today + "。\n"
- + "输出格式:{\"summary\":\"简要说明识别到的查询意图\",\"types\":[],\"categoryNames\":[],\"tagNames\":[],"
- + "\"channels\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null,"
- + "\"maxAmount\":null,\"remark\":null,\"includeInStats\":null,\"uncategorized\":false,\"unmatched\":[]}";
+ return promptTemplateService.querySystemPrompt(
+ TransactionType.describe(),
+ ChannelType.describe(),
+ categoryNames.isEmpty() ? "无" : categoryNames,
+ tagNames.isEmpty() ? "无" : tagNames,
+ LocalDate.now().format(DATE_FMT));
}
/**
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 3bb57c5..a92e442 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiRecordServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiRecordServiceImpl.java
@@ -25,6 +25,7 @@ import cn.rosercode.finance.enums.ChannelType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
+import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiRecordService;
import cn.rosercode.finance.service.AiUsageService;
@@ -53,19 +54,22 @@ public class AiRecordServiceImpl implements AiRecordService {
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
+ private final PromptTemplateService promptTemplateService;
public AiRecordServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
TagMapper tagMapper,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
- ObjectMapper objectMapper) {
+ ObjectMapper objectMapper,
+ PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
+ this.promptTemplateService = promptTemplateService;
}
/**
@@ -119,22 +123,14 @@ public class AiRecordServiceImpl implements AiRecordService {
private String buildSystemPrompt(List categories, List tags) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
- String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
- return "你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。\n"
- + "可用的收支类型:" + TransactionType.describe() + "。\n"
- + "可用的交易渠道:" + ChannelType.describe() + "。\n"
- + "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
- + "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
- + "规则:1. type 默认为 EXPENSE,除非明显是收入;2. amount 为必填数字;"
- + "3. categoryNames 从可用分类中选择最合适的一个(数组,最多一个),无法判断则为空数组;"
- + "4. tagNames 从可用标签中选择(数组,可为空);5. channel 从可用交易渠道中选择,无法判断为 null;"
- + "6. transactionTime 输出 yyyy-MM-ddTHH:mm:ss,未提及时间用当前时间,相对时间(如昨天、上周五)请换算;"
- + "7. includeInStats 默认 true;8. remark 给出简洁备注。\n"
- + "当前时间:" + now + "。\n"
- + "输出格式:{\"summary\":\"简要说明识别到的记账内容\",\"type\":\"EXPENSE\",\"amount\":35.5,"
- + "\"categoryNames\":[\"餐饮\"],\"tagNames\":[],\"channel\":\"ALIPAY\","
- + "\"transactionTime\":\"" + LocalDateTime.now().format(DATETIME_FMT) + "\",\"remark\":\"午餐\","
- + "\"includeInStats\":true,\"unmatched\":[]}";
+ LocalDateTime current = LocalDateTime.now();
+ return promptTemplateService.recordSystemPrompt(
+ TransactionType.describe(),
+ ChannelType.describe(),
+ categoryNames.isEmpty() ? "无" : categoryNames,
+ tagNames.isEmpty() ? "无" : tagNames,
+ current.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
+ current.format(DATETIME_FMT));
}
/**
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiRuleServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiRuleServiceImpl.java
index d1f58ea..c5859c0 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiRuleServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiRuleServiceImpl.java
@@ -38,6 +38,7 @@ 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.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiRuleService;
import cn.rosercode.finance.service.AiUsageService;
@@ -80,24 +81,6 @@ public class AiRuleServiceImpl implements AiRuleService {
/** 合法的十六进制颜色 */
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
- /** 系统提示词,要求模型严格返回 JSON */
- private static final String SYSTEM_PROMPT = "你是一个个人记账应用的规则生成助手。"
- + "请根据给定的已打标签记录,总结出可用于自动分类与打标签的规则,只输出 JSON。\n"
- + "每条记录包含收支类型、备注、交易渠道、已有分类(category)与已有标签(tags),请据此归纳可复用的关键词规则。\n"
- + "规则字段说明:\n"
- + "- name:规则名称,简洁中文,不超过 20 字;\n"
- + "- type:限定收支类型,只能是 INCOME(收入)或 EXPENSE(支出),无法判断为 null;\n"
- + "- keyword:匹配备注的关键词(包含匹配,忽略大小写),无法判断为 null;\n"
- + "- categoryId:从「可用分类」中选择最合适的分类ID,无法对应时为 null;\n"
- + "- newCategory:现有分类都不合适时给出 {\"name\":\"分类名\",\"type\":\"EXPENSE\",\"icon\":\"🚌\"},否则为 null;\n"
- + "- tagIds:从「可用标签」中选择,数组;\n"
- + "- newTags:需要新建的标签,格式 [{\"name\":\"标签名\",\"color\":\"#2f80ed\"}],可为空数组;\n"
- + "- reason:简要中文理由。\n"
- + "优先依据记录已有的标签与分类归纳规则;规则应聚焦于可复用的关键词,避免一条规则只对应单笔记录。\n"
- + "输出格式:{\"summary\":\"简要中文摘要\",\"rules\":[{\"name\":\"...\",\"type\":\"EXPENSE\","
- + "\"keyword\":\"星巴克\",\"categoryId\":12,\"newCategory\":null,\"tagIds\":[3],"
- + "\"newTags\":[],\"reason\":\"...\"}]}";
-
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
@@ -107,6 +90,7 @@ public class AiRuleServiceImpl implements AiRuleService {
private final TagMapper tagMapper;
private final TransactionTagMapper transactionTagMapper;
private final RuleService ruleService;
+ private final PromptTemplateService promptTemplateService;
public AiRuleServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
@@ -116,7 +100,8 @@ public class AiRuleServiceImpl implements AiRuleService {
CategoryMapper categoryMapper,
TagMapper tagMapper,
TransactionTagMapper transactionTagMapper,
- RuleService ruleService) {
+ RuleService ruleService,
+ PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
@@ -126,6 +111,7 @@ public class AiRuleServiceImpl implements AiRuleService {
this.tagMapper = tagMapper;
this.transactionTagMapper = transactionTagMapper;
this.ruleService = ruleService;
+ this.promptTemplateService = promptTemplateService;
}
/**
@@ -169,7 +155,7 @@ public class AiRuleServiceImpl implements AiRuleService {
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
- SYSTEM_PROMPT, userPrompt);
+ promptTemplateService.ruleSystemPrompt(), userPrompt);
} catch (RuntimeException e) {
aiUsageService.recordFailure(userId, AiCallType.RULE, config.getModel(), transactions.size(),
System.currentTimeMillis() - start, e.getMessage());
@@ -456,6 +442,9 @@ public class AiRuleServiceImpl implements AiRuleService {
/**
* 构建用户提示词,将可用分类、标签与已打标签记录序列化为 JSON。
*
+ * 隐私:仅发送白名单字段(分类/标签名称与 ID、交易类型、备注、交易渠道),
+ * 禁止加入 amount、rawData、externalNo 等敏感字段。
+ *
* @param categories 用户分类列表
* @param tags 用户标签列表
* @param transactions 选中的记录列表
@@ -492,6 +481,7 @@ public class AiRuleServiceImpl implements AiRuleService {
if (!seen.add(key)) {
continue;
}
+ // 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息
Map node = new LinkedHashMap<>();
node.put("type", transaction.getType());
node.put("remark", transaction.getRemark());
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 2126d25..0ab124c 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiStatisticsServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiStatisticsServiceImpl.java
@@ -28,6 +28,7 @@ import cn.rosercode.finance.enums.StatisticsScope;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
+import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiStatisticsService;
import cn.rosercode.finance.service.AiUsageService;
@@ -68,19 +69,22 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
+ private final PromptTemplateService promptTemplateService;
public AiStatisticsServiceImpl(AiConfigService aiConfigService,
CategoryMapper categoryMapper,
TagMapper tagMapper,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
- ObjectMapper objectMapper) {
+ ObjectMapper objectMapper,
+ PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
+ this.promptTemplateService = promptTemplateService;
}
/**
@@ -134,21 +138,12 @@ public class AiStatisticsServiceImpl implements AiStatisticsService {
private String buildSystemPrompt(List categories, List tags) {
String categoryNames = categories.stream().map(Category::getName).collect(Collectors.joining("、"));
String tagNames = tags.stream().map(Tag::getName).collect(Collectors.joining("、"));
- String today = LocalDate.now().format(DATE_FMT);
- return "你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。\n"
- + "可用的收支类型:" + TransactionType.describe() + "。\n"
- + "可用的交易渠道:" + ChannelType.describe() + "。\n"
- + "可用分类:" + (categoryNames.isEmpty() ? "无" : categoryNames) + "。\n"
- + "可用标签:" + (tagNames.isEmpty() ? "无" : tagNames) + "。\n"
- + "时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、"
- + "LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。\n"
- + "dimension 表示趋势统计的粒度,取值 day、week、month、quarter、year,无法判断为 null。\n"
- + "statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全部)、EXCLUDED(仅不计入统计),无法判断为 null。\n"
- + "未提及的字段请留空(数组用 [],其余用 null)。分类和标签只能使用上面给出的名称。\n"
- + "今天是 " + today + "。\n"
- + "输出格式:{\"summary\":\"简要说明识别到的统计意图\",\"types\":[],\"categoryNames\":[],\"tagNames\":[],"
- + "\"channels\":[],\"dateRange\":{\"preset\":\"NONE\",\"start\":null,\"end\":null},\"minAmount\":null,"
- + "\"maxAmount\":null,\"remark\":null,\"statsScope\":null,\"dimension\":null,\"unmatched\":[]}";
+ return promptTemplateService.statisticsSystemPrompt(
+ TransactionType.describe(),
+ ChannelType.describe(),
+ categoryNames.isEmpty() ? "无" : categoryNames,
+ tagNames.isEmpty() ? "无" : tagNames,
+ LocalDate.now().format(DATE_FMT));
}
/**
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 068aae0..29ec7a9 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiSuggestionServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiSuggestionServiceImpl.java
@@ -36,6 +36,7 @@ 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.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiSuggestionService;
import cn.rosercode.finance.service.AiUsageService;
@@ -68,27 +69,6 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
/** 十六进制颜色校验 */
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
- /** 严格模式系统提示词,仅允许使用现有分类与标签,供编辑弹窗单条建议使用 */
- private static final String STRICT_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,"
- + "从给定的分类列表和标签列表中,为该记录选择最合适的分类与标签。"
- + "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);"
- + "2. 只能使用给定列表中的 ID,不要编造;3. 标签可多选,也可为空数组;"
- + "4. 只输出 JSON,不要输出任何多余内容。"
- + "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID,\"tagIds\":[标签ID],\"reason\":\"简要中文理由\"}]}";
-
- /** 增强模式系统提示词,允许在现有候选都不合适时提出新建分类与标签 */
- private static final String NEW_SYSTEM_PROMPT = "你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,"
- + "为该记录选择合适的分类与标签。优先从给定的分类列表和标签列表中选择,如果列表中确实没有合适的,可以提出新建。"
- + "要求:1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);"
- + "2. 优先使用给定列表中的 ID,不要编造列表之外的 ID;"
- + "3. 现有列表都不合适时,用 newCategory 提出新分类(name 为 2-6 个汉字,type 与记录类型一致,icon 为最贴切的一个 Emoji),"
- + "或(和)用 newTags 提出新标签(name 为 2-6 个汉字,color 为高饱和、搭配白色文字清晰的十六进制颜色);"
- + "4. 已有合适分类时 categoryId 填其 ID 且 newCategory 为 null;已有合适标签时 tagIds 填其 ID 且 newTags 为空数组;"
- + "5. 标签可多选,也可为空数组;6. 只输出 JSON,不要输出任何多余内容。"
- + "输出格式:{\"suggestions\":[{\"id\":记录ID,\"categoryId\":分类ID或null,"
- + "\"newCategory\":{\"name\":\"分类名\",\"type\":\"EXPENSE\",\"icon\":\"🐾\"}或null,"
- + "\"tagIds\":[标签ID],\"newTags\":[{\"name\":\"标签名\",\"color\":\"#2f80ed\"}],\"reason\":\"简要中文理由\"}]}";
-
private final AiConfigService aiConfigService;
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
@@ -97,6 +77,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
private final DeepSeekClient deepSeekClient;
private final ObjectMapper objectMapper;
private final AiUsageService aiUsageService;
+ private final PromptTemplateService promptTemplateService;
public AiSuggestionServiceImpl(AiConfigService aiConfigService,
TransactionMapper transactionMapper,
@@ -105,7 +86,8 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
TransactionTagMapper transactionTagMapper,
DeepSeekClient deepSeekClient,
ObjectMapper objectMapper,
- AiUsageService aiUsageService) {
+ AiUsageService aiUsageService,
+ PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
@@ -114,6 +96,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
this.deepSeekClient = deepSeekClient;
this.objectMapper = objectMapper;
this.aiUsageService = aiUsageService;
+ this.promptTemplateService = promptTemplateService;
}
/**
@@ -242,7 +225,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
- STRICT_SYSTEM_PROMPT, userPrompt);
+ promptTemplateService.suggestionStrictSystemPrompt(), userPrompt);
} catch (RuntimeException e) {
aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), 1,
System.currentTimeMillis() - start, e.getMessage());
@@ -348,13 +331,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
* @return 系统提示词
*/
private String buildSystemPrompt(String scope) {
- if ("CATEGORY".equals(scope)) {
- return NEW_SYSTEM_PROMPT + "注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。";
- }
- if ("TAG".equals(scope)) {
- return NEW_SYSTEM_PROMPT + "注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。";
- }
- return NEW_SYSTEM_PROMPT;
+ return promptTemplateService.suggestionNewSystemPrompt(scope);
}
/**
@@ -573,6 +550,9 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
/**
* 构建用户提示词,将分类、标签与交易记录序列化为 JSON。
*
+ * 隐私:仅发送白名单字段(分类/标签名称与 ID、交易类型、交易时间、渠道、备注),
+ * 禁止加入 amount、rawData、externalNo 等敏感字段。
+ *
* @param categories 用户分类列表
* @param tags 用户标签列表
* @param transactions 待分析记录列表
@@ -593,6 +573,7 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
node.put("name", tag.getName());
return node;
}).collect(Collectors.toList()));
+ // 隐私白名单:仅发送下列字段,禁止加入 amount / rawData / externalNo 等敏感信息
payload.put("transactions", transactions.stream().map(transaction -> {
Map node = new LinkedHashMap<>();
node.put("id", transaction.getId());
diff --git a/src/main/java/cn/rosercode/finance/service/impl/AiTagServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/AiTagServiceImpl.java
index bf15e79..9001c26 100644
--- a/src/main/java/cn/rosercode/finance/service/impl/AiTagServiceImpl.java
+++ b/src/main/java/cn/rosercode/finance/service/impl/AiTagServiceImpl.java
@@ -12,6 +12,7 @@ import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
+import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiTagService;
import cn.rosercode.finance.service.AiUsageService;
@@ -34,27 +35,22 @@ public class AiTagServiceImpl implements AiTagService {
/** 十六进制颜色校验 */
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
- /** 系统提示词,要求模型严格返回 JSON */
- private static final String SYSTEM_PROMPT = "你是一个个人记账应用的标签助手。"
- + "请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。"
- + "要求:1. name 为 2-6 个汉字的标签名称;"
- + "2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色;"
- + "3. 只输出 JSON,不要输出任何多余内容。"
- + "输出格式:{\"name\":\"报销\",\"color\":\"#2f80ed\",\"reason\":\"简要中文理由\"}";
-
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
+ private final PromptTemplateService promptTemplateService;
public AiTagServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
- ObjectMapper objectMapper) {
+ ObjectMapper objectMapper,
+ PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
+ this.promptTemplateService = promptTemplateService;
}
/**
@@ -73,7 +69,7 @@ public class AiTagServiceImpl implements AiTagService {
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
- SYSTEM_PROMPT, text.trim());
+ promptTemplateService.tagSystemPrompt(), text.trim());
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.TAG, config.getModel(), 0,
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index af3c68b..e118f83 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -5,6 +5,13 @@ spring:
multipart:
max-file-size: 10MB
max-request-size: 10MB
+ # AI 提示词模板:TEXT 模式加载 classpath:/prompts/*.tpl,
+ # 由 cn.rosercode.finance.prompt.PromptTemplateService 统一渲染
+ thymeleaf:
+ prefix: classpath:/prompts/
+ suffix: .tpl
+ mode: TEXT
+ encoding: UTF-8
# 数据源不再在此固定配置,改由运行时数据库配置(config/database.json)决定,
# 详见 cn.rosercode.finance.config.datasource 包。
diff --git a/src/main/resources/prompts/ai-category-system.tpl b/src/main/resources/prompts/ai-category-system.tpl
new file mode 100644
index 0000000..8548346
--- /dev/null
+++ b/src/main/resources/prompts/ai-category-system.tpl
@@ -0,0 +1,5 @@
+你是一个个人记账应用的分类助手。请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。要求:
+1. name 为 2-6 个汉字的分类名称;
+2. type 只能是 [(${typeDesc})];
+3. icon 为与该分类最贴切的一个 Emoji;
+4. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通","type":"EXPENSE","icon":"🚌","reason":"简要中文理由"}
\ No newline at end of file
diff --git a/src/main/resources/prompts/ai-config-test-system.tpl b/src/main/resources/prompts/ai-config-test-system.tpl
new file mode 100644
index 0000000..2e5156b
--- /dev/null
+++ b/src/main/resources/prompts/ai-config-test-system.tpl
@@ -0,0 +1 @@
+你是一个测试助手,请只输出 JSON。
\ No newline at end of file
diff --git a/src/main/resources/prompts/ai-config-test-user.tpl b/src/main/resources/prompts/ai-config-test-user.tpl
new file mode 100644
index 0000000..cbeac32
--- /dev/null
+++ b/src/main/resources/prompts/ai-config-test-user.tpl
@@ -0,0 +1 @@
+请返回 {"ok":true}
\ No newline at end of file
diff --git a/src/main/resources/prompts/ai-decoration-color-system.tpl b/src/main/resources/prompts/ai-decoration-color-system.tpl
new file mode 100644
index 0000000..9a6d390
--- /dev/null
+++ b/src/main/resources/prompts/ai-decoration-color-system.tpl
@@ -0,0 +1,2 @@
+你是一个个人记账应用的配色助手。请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。
+只输出 JSON,不要输出任何多余内容。输出格式:{"color":"#18a058"}
\ No newline at end of file
diff --git a/src/main/resources/prompts/ai-decoration-emoji-system.tpl b/src/main/resources/prompts/ai-decoration-emoji-system.tpl
new file mode 100644
index 0000000..fc8a726
--- /dev/null
+++ b/src/main/resources/prompts/ai-decoration-emoji-system.tpl
@@ -0,0 +1,2 @@
+你是一个个人记账应用的图标助手。请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。
+只输出 JSON,不要输出任何多余内容。输出格式:{"emoji":"🍜"}
\ No newline at end of file
diff --git a/src/main/resources/prompts/ai-query-system.tpl b/src/main/resources/prompts/ai-query-system.tpl
new file mode 100644
index 0000000..259eae8
--- /dev/null
+++ b/src/main/resources/prompts/ai-query-system.tpl
@@ -0,0 +1,11 @@
+你是一个记账系统的查询解析助手。请把用户的自然语言查询解析为结构化筛选条件,只输出 JSON,不要输出任何多余内容。
+可用的收支类型:[(${typeDesc})]。
+可用的交易渠道:[(${channelDesc})]。
+可用分类:[(${categoryNames})]。
+可用标签:[(${tagNames})]。
+时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;
+若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。
+未提及的字段请留空(数组用 [],其余用 null)。
+分类和标签只能使用上面给出的名称。
+今天是 [(${today})]。
+输出格式:{"summary":"简要说明识别到的查询意图","types":[],"categoryNames":[],"tagNames":[],"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"includeInStats":null,"uncategorized":false,"unmatched":[]}
\ No newline at end of file
diff --git a/src/main/resources/prompts/ai-record-system.tpl b/src/main/resources/prompts/ai-record-system.tpl
new file mode 100644
index 0000000..e93fe24
--- /dev/null
+++ b/src/main/resources/prompts/ai-record-system.tpl
@@ -0,0 +1,16 @@
+你是一个记账助手。请把用户的自然语言记账内容解析为一条结构化交易记录,只输出 JSON,不要输出任何多余内容。
+可用的收支类型:[(${typeDesc})]。
+可用的交易渠道:[(${channelDesc})]。
+可用分类:[(${categoryNames})]。
+可用标签:[(${tagNames})]。
+规则:
+1. type 默认为 EXPENSE,除非明显是收入;
+2. amount 为必填数字;
+3. categoryNames 从可用分类中选择最合适的一个(数组,最多一个),无法判断则为空数组;
+4. tagNames 从可用标签中选择(数组,可为空);
+5. channel 从可用交易渠道中选择,无法判断为 null;
+6. transactionTime 输出 yyyy-MM-ddTHH:mm:ss,未提及时间用当前时间,相对时间(如昨天、上周五)请换算;
+7. includeInStats 默认 true;
+8. remark 给出简洁备注。
+当前时间:[(${now})]。
+输出格式:{"summary":"简要说明识别到的记账内容","type":"EXPENSE","amount":35.5,"categoryNames":["餐饮"],"tagNames":[],"channel":"ALIPAY","transactionTime":"[(${exampleTime})]","remark":"午餐","includeInStats":true,"unmatched":[]}
\ No newline at end of file
diff --git a/src/main/resources/prompts/ai-rule-system.tpl b/src/main/resources/prompts/ai-rule-system.tpl
new file mode 100644
index 0000000..94410aa
--- /dev/null
+++ b/src/main/resources/prompts/ai-rule-system.tpl
@@ -0,0 +1,13 @@
+你是一个个人记账应用的规则生成助手。请根据给定的已打标签记录,总结出可用于自动分类与打标签的规则,只输出 JSON。
+每条记录包含收支类型、备注、交易渠道、已有分类(category)与已有标签(tags),请据此归纳可复用的关键词规则。
+规则字段说明:
+- name:规则名称,简洁中文,不超过 20 字;
+- type:限定收支类型,只能是 INCOME(收入)或 EXPENSE(支出),无法判断为 null;
+- keyword:匹配备注的关键词(包含匹配,忽略大小写),无法判断为 null;
+- categoryId:从「可用分类」中选择最合适的分类ID,无法对应时为 null;
+- newCategory:现有分类都不合适时给出 {"name":"分类名","type":"EXPENSE","icon":"🚌"},否则为 null;
+- tagIds:从「可用标签」中选择,数组;
+- newTags:需要新建的标签,格式 [{"name":"标签名","color":"#2f80ed"}],可为空数组;
+- reason:简要中文理由。
+优先依据记录已有的标签与分类归纳规则;规则应聚焦于可复用的关键词,避免一条规则只对应单笔记录。
+输出格式:{"summary":"简要中文摘要","rules":[{"name":"...","type":"EXPENSE","keyword":"星巴克","categoryId":12,"newCategory":null,"tagIds":[3],"newTags":[],"reason":"..."}]}
\ No newline at end of file
diff --git a/src/main/resources/prompts/ai-statistics-system.tpl b/src/main/resources/prompts/ai-statistics-system.tpl
new file mode 100644
index 0000000..9d7c049
--- /dev/null
+++ b/src/main/resources/prompts/ai-statistics-system.tpl
@@ -0,0 +1,13 @@
+你是一个记账系统的统计查询解析助手。请把用户的自然语言统计意图解析为结构化统计参数,只输出 JSON,不要输出任何多余内容。
+可用的收支类型:[(${typeDesc})]。
+可用的交易渠道:[(${channelDesc})]。
+可用分类:[(${categoryNames})]。
+可用标签:[(${tagNames})]。
+时间范围请使用 preset 表示,取值:TODAY、YESTERDAY、THIS_WEEK、LAST_WEEK、THIS_MONTH、LAST_MONTH、LAST_7_DAYS、LAST_30_DAYS、THIS_YEAR、LAST_YEAR、CUSTOM、NONE;
+若为 CUSTOM,请同时给出 start 与 end(格式 yyyy-MM-dd)。
+dimension 表示趋势统计的粒度,取值 day、week、month、quarter、year,无法判断为 null。
+statsScope 表示统计口径,取值 INCLUDED(仅计入统计)、ALL(全部)、EXCLUDED(仅不计入统计),无法判断为 null。
+未提及的字段请留空(数组用 [],其余用 null)。
+分类和标签只能使用上面给出的名称。
+今天是 [(${today})]。
+输出格式:{"summary":"简要说明识别到的统计意图","types":[],"categoryNames":[],"tagNames":[],"channels":[],"dateRange":{"preset":"NONE","start":null,"end":null},"minAmount":null,"maxAmount":null,"remark":null,"statsScope":null,"dimension":null,"unmatched":[]}
\ No newline at end of file
diff --git a/src/main/resources/prompts/ai-suggestion-new-system.tpl b/src/main/resources/prompts/ai-suggestion-new-system.tpl
new file mode 100644
index 0000000..917fe90
--- /dev/null
+++ b/src/main/resources/prompts/ai-suggestion-new-system.tpl
@@ -0,0 +1,9 @@
+你是一个专业的个人记账助手。请根据每条交易记录的类型、备注、交易渠道等信息,为该记录选择合适的分类与标签。优先从给定的分类列表和标签列表中选择,如果列表中确实没有合适的,可以提出新建。要求:
+1. 分类必须与记录的类型一致(INCOME 只能选 INCOME 分类,EXPENSE 只能选 EXPENSE 分类);
+2. 优先使用给定列表中的 ID,不要编造列表之外的 ID;
+3. 现有列表都不合适时,用 newCategory 提出新分类(name 为 2-6 个汉字,type 与记录类型一致,icon 为最贴切的一个 Emoji),或(和)用 newTags 提出新标签(name 为 2-6 个汉字,color 为高饱和、搭配白色文字清晰的十六进制颜色);
+4. 已有合适分类时 categoryId 填其 ID 且 newCategory 为 null;已有合适标签时 tagIds 填其 ID 且 newTags 为空数组;
+5. 标签可多选,也可为空数组;
+6. 只输出 JSON,不要输出任何多余内容。输出格式:{"suggestions":[{"id":记录ID,"categoryId":分类ID或null,"newCategory":{"name":"分类名","type":"EXPENSE","icon":"🐾"}或null,"tagIds":[标签ID],"newTags":[{"name":"标签名","color":"#2f80ed"}],"reason":"简要中文理由"}]}
+[# th:if="${scope == 'CATEGORY'}"]注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。[/]
+[# th:if="${scope == 'TAG'}"]注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。[/]
\ No newline at end of file
diff --git a/src/main/resources/prompts/ai-suggestion-strict-system.tpl b/src/main/resources/prompts/ai-suggestion-strict-system.tpl
new file mode 100644
index 0000000..73f7c7e
--- /dev/null
+++ b/src/main/resources/prompts/ai-suggestion-strict-system.tpl
@@ -0,0 +1,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
diff --git a/src/main/resources/prompts/ai-tag-system.tpl b/src/main/resources/prompts/ai-tag-system.tpl
new file mode 100644
index 0000000..c278203
--- /dev/null
+++ b/src/main/resources/prompts/ai-tag-system.tpl
@@ -0,0 +1,4 @@
+你是一个个人记账应用的标签助手。请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。要求:
+1. name 为 2-6 个汉字的标签名称;
+2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色;
+3. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"报销","color":"#2f80ed","reason":"简要中文理由"}
\ No newline at end of file
diff --git a/src/test/java/cn/rosercode/finance/prompt/PromptTemplateServiceTest.java b/src/test/java/cn/rosercode/finance/prompt/PromptTemplateServiceTest.java
new file mode 100644
index 0000000..3b8601f
--- /dev/null
+++ b/src/test/java/cn/rosercode/finance/prompt/PromptTemplateServiceTest.java
@@ -0,0 +1,65 @@
+package cn.rosercode.finance.prompt;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.thymeleaf.spring6.SpringTemplateEngine;
+import org.thymeleaf.templatemode.TemplateMode;
+import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
+
+/**
+ * 提示词模板渲染测试,验证各模板可正常加载、变量替换与 scope 条件分支。
+ *
+ * @date 2026-09-21 11:01:25
+ */
+class PromptTemplateServiceTest {
+
+ private PromptTemplateService promptTemplateService;
+
+ @BeforeEach
+ void setUp() {
+ ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
+ resolver.setPrefix("prompts/");
+ resolver.setSuffix(".tpl");
+ resolver.setTemplateMode(TemplateMode.TEXT);
+ resolver.setCharacterEncoding("UTF-8");
+ SpringTemplateEngine engine = new SpringTemplateEngine();
+ engine.setTemplateResolver(resolver);
+ promptTemplateService = new PromptTemplateService(engine);
+ }
+
+ @Test
+ void renderRecordSystemPrompt() {
+ String result = promptTemplateService.recordSystemPrompt(
+ "INCOME(收入)、EXPENSE(支出)",
+ "ALIPAY(支付宝)",
+ "餐饮",
+ "无",
+ "2026-09-21 11:00:00",
+ "2026-09-21T11:00:00");
+ assertThat(result).contains("可用的收支类型:INCOME(收入)、EXPENSE(支出)。");
+ assertThat(result).contains("可用分类:餐饮。");
+ assertThat(result).contains("可用标签:无。");
+ assertThat(result).contains("当前时间:2026-09-21 11:00:00。");
+ assertThat(result).contains("\"transactionTime\":\"2026-09-21T11:00:00\"");
+ }
+
+ @Test
+ void renderSuggestionScopeSuffix() {
+ String both = promptTemplateService.suggestionNewSystemPrompt("BOTH");
+ assertThat(both).doesNotContain("注意:本次只需给出");
+ String category = promptTemplateService.suggestionNewSystemPrompt("CATEGORY");
+ assertThat(category).contains("注意:本次只需给出分类建议,tagIds 必须返回空数组,newTags 必须返回空数组。");
+ String tag = promptTemplateService.suggestionNewSystemPrompt("TAG");
+ assertThat(tag).contains("注意:本次只需给出标签建议,categoryId 必须返回 null,newCategory 必须返回 null。");
+ }
+
+ @Test
+ void renderStaticPrompts() {
+ assertThat(promptTemplateService.tagSystemPrompt()).contains("你是一个个人记账应用的标签助手。");
+ assertThat(promptTemplateService.ruleSystemPrompt()).contains("你是一个个人记账应用的规则生成助手。");
+ assertThat(promptTemplateService.configTestSystemPrompt()).contains("你是一个测试助手,请只输出 JSON。");
+ assertThat(promptTemplateService.configTestUserPrompt()).contains("请返回 {\"ok\":true}");
+ }
+}
diff --git a/src/test/java/cn/rosercode/finance/service/impl/AiPromptPayloadPrivacyTest.java b/src/test/java/cn/rosercode/finance/service/impl/AiPromptPayloadPrivacyTest.java
new file mode 100644
index 0000000..3c4ecba
--- /dev/null
+++ b/src/test/java/cn/rosercode/finance/service/impl/AiPromptPayloadPrivacyTest.java
@@ -0,0 +1,124 @@
+package cn.rosercode.finance.service.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import cn.rosercode.finance.entity.Category;
+import cn.rosercode.finance.entity.Tag;
+import cn.rosercode.finance.entity.Transaction;
+
+/**
+ * AI 用户提示词隐私测试。
+ *
+ * 校验 {@code buildUserPrompt} 生成的 JSON 载荷仅含白名单字段,
+ * 不含 amount、rawData、externalNo 等敏感信息,防止后续改动误加字段导致数据外发。
+ *
+ * @date 2026-09-21 11:10:58
+ */
+class AiPromptPayloadPrivacyTest {
+
+ /** 禁止出现在提示词载荷中的敏感字段名 */
+ private static final List SENSITIVE_FIELDS = List.of("amount", "rawData", "externalNo");
+
+ /**
+ * AI 建议的批量用户提示词不得包含敏感字段。
+ *
+ * @throws Exception 反射调用失败时抛出
+ */
+ @Test
+ void suggestionUserPromptExcludesSensitiveFields() throws Exception {
+ AiSuggestionServiceImpl service = new AiSuggestionServiceImpl(
+ null, null, null, null, null, null, new ObjectMapper().findAndRegisterModules(), null, null);
+ Method method = AiSuggestionServiceImpl.class.getDeclaredMethod(
+ "buildUserPrompt", List.class, List.class, List.class);
+ method.setAccessible(true);
+
+ String json = (String) method.invoke(service,
+ List.of(category()), List.of(tag()), List.of(transaction()));
+
+ assertExcludesSensitiveFields(json);
+ assertThat(json).contains("remark").contains("午餐");
+ }
+
+ /**
+ * AI 规则生成的用户提示词不得包含敏感字段。
+ *
+ * @throws Exception 反射调用失败时抛出
+ */
+ @Test
+ void ruleUserPromptExcludesSensitiveFields() throws Exception {
+ AiRuleServiceImpl service = new AiRuleServiceImpl(
+ null, null, null, new ObjectMapper().findAndRegisterModules(), null, null, null, null, null, null);
+ Method method = AiRuleServiceImpl.class.getDeclaredMethod(
+ "buildUserPrompt", List.class, List.class, List.class, Map.class);
+ method.setAccessible(true);
+
+ String json = (String) method.invoke(service, List.of(category()), List.of(tag()),
+ List.of(transaction()), Map.of(1L, List.of("报销")));
+
+ assertExcludesSensitiveFields(json);
+ assertThat(json).contains("remark").contains("午餐");
+ }
+
+ /**
+ * 断言载荷不含任何敏感字段名。
+ *
+ * @param json 提示词载荷
+ */
+ private void assertExcludesSensitiveFields(String json) {
+ for (String field : SENSITIVE_FIELDS) {
+ assertThat(json).doesNotContain(field);
+ }
+ }
+
+ /**
+ * 构造用于测试的分类。
+ *
+ * @return 分类实体
+ */
+ private Category category() {
+ Category category = new Category();
+ category.setId(1L);
+ category.setName("餐饮");
+ category.setType("EXPENSE");
+ return category;
+ }
+
+ /**
+ * 构造用于测试的标签。
+ *
+ * @return 标签实体
+ */
+ private Tag tag() {
+ Tag tag = new Tag();
+ tag.setId(1L);
+ tag.setName("报销");
+ return tag;
+ }
+
+ /**
+ * 构造用于测试的交易记录,并刻意写入敏感字段以验证其不会外发。
+ *
+ * @return 交易记录实体
+ */
+ private Transaction transaction() {
+ Transaction transaction = new Transaction();
+ transaction.setId(1L);
+ transaction.setType("EXPENSE");
+ transaction.setChannel("ALIPAY");
+ transaction.setRemark("午餐");
+ transaction.setTransactionTime(LocalDateTime.now());
+ transaction.setAmount(new BigDecimal("35.50"));
+ transaction.setExternalNo("2026092100001");
+ transaction.setRawData("{\"secret\":\"raw\"}");
+ return transaction;
+ }
+}