feat: overhaul chat architecture with persistent JdbcChatMemory, reactive UI components, and improved conversation management

This commit is contained in:
2026-07-23 19:20:28 +07:00
parent 2bbbff1ec9
commit 506008c69f
79 changed files with 5058 additions and 1193 deletions

View File

@@ -1,5 +1,6 @@
package dev.sonpx.loyalty.mcp.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -11,7 +12,11 @@ import org.springframework.context.annotation.Configuration;
public class AppConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.findAndRegisterModules();
mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_EMPTY);
return mapper;
}
}

View File

@@ -0,0 +1,41 @@
package dev.sonpx.loyalty.mcp.reward.dto;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import dev.sonpx.loyalty.mcp.reward.enums.RuleType;
import dev.sonpx.loyalty.mcp.reward.model.ReferenceData;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CampaignRuleSummaryDto implements Serializable {
@JsonPropertyDescription("Mã định danh của rule")
private String ruleId;
@JsonPropertyDescription("Tên của rule")
private String ruleName;
@JsonPropertyDescription("Loại rule: RED, REP, AWD, ADJ, MAWD, CEP")
private RuleType ruleType;
@JsonPropertyDescription("Tham chiếu đến Campaign chứa rule này")
private ReferenceData campaignId;
@JsonPropertyDescription("Thời gian bắt đầu có hiệu lực của rule")
private LocalDateTime effectiveFrom;
@JsonPropertyDescription("Thời gian kết thúc hiệu lực của rule")
private LocalDateTime effectiveTo;
@JsonPropertyDescription("Tham chiếu đến Pool chính")
private ReferenceData poolId;
}

View File

@@ -0,0 +1,43 @@
package dev.sonpx.loyalty.mcp.reward.dto;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import dev.sonpx.loyalty.mcp.reward.enums.CampaignType;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CampaignSummaryDto implements Serializable {
@JsonPropertyDescription("Mã chiến dịch")
private String campaignId;
@JsonPropertyDescription("Tên hiển thị của chiến dịch")
private String name;
@JsonPropertyDescription("Tên người hoặc phòng ban sở hữu chiến dịch")
private String ownerName;
@JsonPropertyDescription("Loại chiến dịch (POINT, VOUCHER...)")
private CampaignType campaignType;
@JsonPropertyDescription("Ngày bắt đầu có hiệu lực")
private LocalDate effectiveFrom;
@JsonPropertyDescription("Ngày kết thúc hiệu lực")
private LocalDate effectiveTo;
@JsonPropertyDescription("Số lượng quy tắc (rules) trong chiến dịch")
private Integer numOfRule;
@JsonPropertyDescription("Mô tả chi tiết")
private String description;
}

View File

@@ -2,6 +2,7 @@
package dev.sonpx.loyalty.mcp.reward.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import java.time.Instant;
@@ -15,27 +16,35 @@ import lombok.Setter;
@Setter
public abstract class Fw implements Serializable {
@JsonIgnore
@JsonFormat(shape = JsonFormat.Shape.STRING)
protected Long recordNo;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String status;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String lastUpdateBy;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected Instant lastUpdateDate;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String lastApproveBy;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected Instant lastApproveDate;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String lastUpdateByName;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String lastApproveByName;
}

View File

@@ -4,6 +4,8 @@ import dev.sonpx.loyalty.mcp.model.OperationResult;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.reward.client.CampaignRuleClient;
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignRuleCriteria;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignRuleSummaryDto;
import dev.sonpx.loyalty.mcp.reward.model.CampaignFormulaOne;
import dev.sonpx.loyalty.mcp.reward.model.CampaignRule;
import java.util.List;
import java.util.Map;
@@ -22,9 +24,12 @@ public class CampaignRuleService {
private final CampaignRuleClient campaignRuleClient;
public Result<List<CampaignRule>> getAll(CampaignRuleCriteria criteria) {
public Result<List<CampaignRuleSummaryDto>> getAll(CampaignRuleCriteria criteria) {
List<CampaignRule> rules = campaignRuleClient.getAll(criteria, Pageable.ofSize(10));
return Result.of(rules, """
List<CampaignRuleSummaryDto> summaries = rules != null
? rules.stream().map(this::toSummaryDto).toList()
: List.of();
return Result.of(summaries, """
Trả lời tự nhiên bằng tiếng Việt. Không dùng markdown table.
Hiển thị mỗi rule theo dạng danh sách đánh số:
**[ruleName]** (`[ruleId]`) — Chiến dịch: [campaignId.description hoặc campaignId.code], Loại: [ruleType - dùng tên tiếng Việt], Hiệu lực: [effectiveFrom] → [effectiveTo]
@@ -34,6 +39,21 @@ public class CampaignRuleService {
""");
}
public CampaignRuleSummaryDto toSummaryDto(CampaignRule rule) {
if (rule == null) {
return null;
}
return CampaignRuleSummaryDto.builder()
.ruleId(rule.getRuleId())
.ruleName(rule.getRuleName())
.ruleType(rule.getRuleType())
.campaignId(rule.getCampaignId())
.effectiveFrom(rule.getEffectiveFrom())
.effectiveTo(rule.getEffectiveTo())
.poolId(rule.getPoolId())
.build();
}
public Result<Long> count(CampaignRuleCriteria criteria) {
Long count = campaignRuleClient.count(criteria);
return Result.of(count, """
@@ -44,6 +64,7 @@ public class CampaignRuleService {
public Result<CampaignRule> findActiveById(String id) {
CampaignRule rule = campaignRuleClient.findActiveById(id);
sanitizeFormulas(rule);
return Result.of(rule, """
Trả lời tự nhiên bằng tiếng Việt. Trình bày chi tiết rule một cách rõ ràng:
- Thông tin cơ bản: Tên, Mã Rule, Loại rule (tên tiếng Việt), Chiến dịch liên kết
@@ -60,6 +81,9 @@ public class CampaignRuleService {
public Result<List<CampaignRule>> findActiveByIds(String ids) {
List<String> idList = java.util.Arrays.asList(ids.split(",\\s*"));
List<CampaignRule> rules = campaignRuleClient.findActiveByIds(idList);
if (rules != null) {
rules.forEach(this::sanitizeFormulas);
}
return Result.of(rules, """
Trả lời tự nhiên bằng tiếng Việt. Hiển thị danh sách rule theo dạng đánh số,
mỗi rule ghi: Tên, Mã Rule, Chiến dịch, Loại rule, Trạng thái.
@@ -67,6 +91,52 @@ public class CampaignRuleService {
""");
}
public void sanitizeFormulas(CampaignRule rule) {
if (rule == null) {
return;
}
if (rule.getCampaignFormulaOne() != null && isFormulaOneEmpty(rule.getCampaignFormulaOne())) {
rule.setCampaignFormulaOne(null);
}
if (rule.getCampaignFormulaTwo() != null && rule.getCampaignFormulaTwo().getFixedAmt() == null) {
rule.setCampaignFormulaTwo(null);
}
if (rule.getCampaignFormulaFour() != null && (rule.getCampaignFormulaFour().getTiers() == null || rule.getCampaignFormulaFour().getTiers().isEmpty())) {
rule.setCampaignFormulaFour(null);
}
if (rule.getCampaignFormulaSix() != null && (rule.getCampaignFormulaSix().getTiers() == null || rule.getCampaignFormulaSix().getTiers().isEmpty())) {
rule.setCampaignFormulaSix(null);
}
if (rule.getCampaignFormulaFives() != null && rule.getCampaignFormulaFives().isEmpty()) {
rule.setCampaignFormulaFives(null);
}
if (rule.getCampaignFormulaEights() != null && rule.getCampaignFormulaEights().isEmpty()) {
rule.setCampaignFormulaEights(null);
}
if (rule.getCampaignFormulaTens() != null && rule.getCampaignFormulaTens().isEmpty()) {
rule.setCampaignFormulaTens(null);
}
if (rule.getCampaignFormulaElevens() != null && rule.getCampaignFormulaElevens().isEmpty()) {
rule.setCampaignFormulaElevens(null);
}
if (rule.getCampaignAwardLimits() != null && rule.getCampaignAwardLimits().isEmpty()) {
rule.setCampaignAwardLimits(null);
}
if (rule.getCampaignTcLinkages() != null && rule.getCampaignTcLinkages().isEmpty()) {
rule.setCampaignTcLinkages(null);
}
if (rule.getCampaignContributors() != null && rule.getCampaignContributors().isEmpty()) {
rule.setCampaignContributors(null);
}
}
private boolean isFormulaOneEmpty(CampaignFormulaOne f) {
return f.getAwardPerTxnAmt() == null
&& f.getTxnAmt() == null
&& f.getAwardPerTxnAmtAfter() == null
&& f.getTxnAmtAfter() == null;
}
public Result<String> generateId() {
String id = campaignRuleClient.generateId();
return Result.of(id, """
@@ -93,3 +163,4 @@ public class CampaignRuleService {
""");
}
}

View File

@@ -4,6 +4,7 @@ import dev.sonpx.loyalty.mcp.model.OperationResult;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.reward.client.CampaignClient;
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignCriteria;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignSummaryDto;
import dev.sonpx.loyalty.mcp.reward.model.Campaign;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -21,9 +22,12 @@ public class CampaignService {
private final CampaignClient campaignClient;
public Result<List<Campaign>> getAll(CampaignCriteria criteria) {
public Result<List<CampaignSummaryDto>> getAll(CampaignCriteria criteria) {
List<Campaign> campaigns = campaignClient.getAll(criteria, Pageable.ofSize(5));
return Result.of(campaigns, """
List<CampaignSummaryDto> summaries = campaigns != null
? campaigns.stream().map(this::toSummaryDto).toList()
: List.of();
return Result.of(summaries, """
Trả lời tự nhiên bằng tiếng Việt. Không dùng markdown table.
Hiển thị mỗi chiến dịch theo dạng danh sách đánh số:
**[Tên]** (`[campaignId]`) — Owner: [ownerName hoặc "Chưa có"], Loại: [campaignType], Hiệu lực: [effectiveFrom] → [effectiveTo], Số rule: [numOfRule]
@@ -33,6 +37,22 @@ public class CampaignService {
""");
}
public CampaignSummaryDto toSummaryDto(Campaign campaign) {
if (campaign == null) {
return null;
}
return CampaignSummaryDto.builder()
.campaignId(campaign.getCampaignId())
.name(campaign.getName())
.ownerName(campaign.getOwnerName())
.campaignType(campaign.getCampaignType())
.effectiveFrom(campaign.getEffectiveFrom())
.effectiveTo(campaign.getEffectiveTo())
.numOfRule(campaign.getNumOfRule())
.description(campaign.getDescription())
.build();
}
public Result<Long> count(CampaignCriteria criteria) {
Long count = campaignClient.count(criteria);
return Result.of(count, """
@@ -91,3 +111,4 @@ public class CampaignService {
""");
}
}

View File

@@ -3,6 +3,7 @@ package dev.sonpx.loyalty.mcp.tool;
import dev.sonpx.loyalty.mcp.model.OperationResult;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignRuleCriteria;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignRuleSummaryDto;
import dev.sonpx.loyalty.mcp.reward.model.CampaignRule;
import dev.sonpx.loyalty.mcp.service.CampaignRuleService;
import dev.sonpx.loyalty.mcp.util.SearchUtils;
@@ -26,12 +27,13 @@ public class CampaignRuleTools {
- QUAN TRỌNG: Khi người dùng hỏi lấy danh sách chung ("danh sách rule", "xem thể lệ"), KHÔNG truyền param `search` (để null hoặc rỗng ""). Tuyệt đối KHÔNG truyền các từ chung như "rule", "thể lệ", "danh sách" vào `search`.
- KHÔNG dùng tool này khi người dùng chỉ hỏi về thông tin cơ bản chiến dịch (tên, owner, loại) → hãy dùng tool searchCampaigns.
Hiển thị tối đa 10 bản ghi.""")
public Result<List<CampaignRule>> searchRules(String search) {
public Result<List<CampaignRuleSummaryDto>> searchRules(String search) {
CampaignRuleCriteria criteria = new CampaignRuleCriteria();
criteria.setSearch(SearchUtils.sanitizeSearch(search));
return campaignRuleService.getAll(criteria);
}
@McpTool(description = "Đếm số lượng RULE/THỂ LỆ, không phải chiến dịch. Để search = null nếu đếm tất cả.")
public Result<Long> countRules(String search) {
CampaignRuleCriteria criteria = new CampaignRuleCriteria();

View File

@@ -3,6 +3,7 @@ package dev.sonpx.loyalty.mcp.tool;
import dev.sonpx.loyalty.mcp.model.OperationResult;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignCriteria;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignSummaryDto;
import dev.sonpx.loyalty.mcp.reward.model.Campaign;
import dev.sonpx.loyalty.mcp.service.CampaignService;
import dev.sonpx.loyalty.mcp.util.SearchUtils;
@@ -25,12 +26,13 @@ public class CampaignTools {
- Param `search` (String): từ khóa tìm kiếm theo tên hoặc mã chiến dịch cụ thể.
- QUAN TRỌNG: Khi người dùng hỏi lấy danh sách chung ("danh sách chiến dịch", "xem các chiến dịch"), KHÔNG truyền param `search` (để null hoặc rỗng ""). Tuyệt đối KHÔNG truyền các từ chung như "chiến dịch", "danh sách", "campaign" vào `search`.
- KHÔNG dùng tool này khi người dùng hỏi về: rule, thể lệ, quy tắc, điều kiện, công thức thưởng → hãy dùng tool searchRules.""")
public Result<List<Campaign>> searchCampaigns(String search) {
public Result<List<CampaignSummaryDto>> searchCampaigns(String search) {
CampaignCriteria criteria = new CampaignCriteria();
criteria.setSearch(SearchUtils.sanitizeSearch(search));
return campaignService.getAll(criteria);
}
@McpTool(description = "Đếm số lượng CHIẾN DỊCH (Campaign), không phải rule. Để search = null nếu đếm tất cả.")
public Result<Long> countCampaigns(String search) {
CampaignCriteria criteria = new CampaignCriteria();

View File

@@ -2,7 +2,7 @@ package dev.sonpx.loyalty.mcp.web.rest;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignCriteria;
import dev.sonpx.loyalty.mcp.reward.model.Campaign;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignSummaryDto;
import dev.sonpx.loyalty.mcp.service.CampaignService;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -22,8 +22,9 @@ public class RewardResource {
private final CampaignService campaignService;
@GetMapping
public ResponseEntity<Result<List<Campaign>>> getAll(CampaignCriteria criteria) {
public ResponseEntity<Result<List<CampaignSummaryDto>>> getAll(CampaignCriteria criteria) {
return ResponseEntity.ok(campaignService.getAll(criteria));
}
}

View File

@@ -13,6 +13,7 @@ spring:
server:
sse-endpoint: /sse
sse-message-endpoint: /message
keep-alive-interval: 30s
security:
oauth2:
client:
@@ -35,4 +36,6 @@ logging:
level:
root: warn
org.springframework.ai: INFO
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR
org.springframework.security.oauth2.client.registration.ClientRegistration: ERROR
dev.sonpx.loyalty: DEBUG

View File

@@ -0,0 +1,131 @@
package dev.sonpx.loyalty.mcp.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignRuleSummaryDto;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignSummaryDto;
import dev.sonpx.loyalty.mcp.reward.enums.CampaignType;
import dev.sonpx.loyalty.mcp.reward.enums.RuleType;
import dev.sonpx.loyalty.mcp.reward.model.Campaign;
import dev.sonpx.loyalty.mcp.reward.model.CampaignRule;
import dev.sonpx.loyalty.mcp.reward.model.ReferenceData;
import dev.sonpx.loyalty.mcp.service.CampaignRuleService;
import dev.sonpx.loyalty.mcp.service.CampaignService;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class McpResponseSerializationTest {
private ObjectMapper objectMapper;
private CampaignService campaignService;
private CampaignRuleService campaignRuleService;
@BeforeEach
void setUp() {
AppConfig config = new AppConfig();
objectMapper = config.objectMapper();
campaignService = new CampaignService(null);
campaignRuleService = new CampaignRuleService(null);
}
@Test
void testFwAuditFieldsIgnoredAndNullsOmittedInCampaign() throws Exception {
Campaign campaign = new Campaign();
campaign.setCampaignId("CMP01");
campaign.setName("Happy Birthday");
campaign.setOwnerName("Marketing");
campaign.setCampaignType(CampaignType.BASE);
campaign.setRecordNo(100L);
campaign.setLastUpdateBy("admin");
campaign.setLastUpdateByName("Admin User");
campaign.setLastApproveBy("checker");
campaign.setLastApproveByName("Checker User");
String json = objectMapper.writeValueAsString(campaign);
// Audit fields must be ignored
assertThat(json).doesNotContain("recordNo");
assertThat(json).doesNotContain("lastUpdateBy");
assertThat(json).doesNotContain("lastUpdateByName");
assertThat(json).doesNotContain("lastApproveBy");
assertThat(json).doesNotContain("lastApproveByName");
assertThat(json).doesNotContain("lastUpdateDate");
assertThat(json).doesNotContain("lastApproveDate");
// Null fields (effectiveFrom, effectiveTo, etc.) must be omitted
assertThat(json).doesNotContain("effectiveFrom");
assertThat(json).doesNotContain("effectiveTo");
assertThat(json).doesNotContain("noOfCustomerTarget");
// Essential fields present
assertThat(json).contains("\"campaignId\":\"CMP01\"");
assertThat(json).contains("\"name\":\"Happy Birthday\"");
}
@Test
void testCampaignSummaryDtoMappingAndSerialization() throws Exception {
Campaign campaign = new Campaign();
campaign.setCampaignId("CMP02");
campaign.setName("Summer Sale");
campaign.setOwnerName("Sales");
campaign.setCampaignType(CampaignType.TACTICAL);
campaign.setEffectiveFrom(LocalDate.of(2026, 6, 1));
campaign.setEffectiveTo(LocalDate.of(2026, 8, 31));
campaign.setNumOfRule(3);
CampaignSummaryDto dto = campaignService.toSummaryDto(campaign);
String json = objectMapper.writeValueAsString(dto);
assertThat(json).contains("\"campaignId\":\"CMP02\"");
assertThat(json).contains("\"name\":\"Summer Sale\"");
assertThat(json).contains("\"ownerName\":\"Sales\"");
assertThat(json).contains("\"numOfRule\":3");
assertThat(json).doesNotContain("description"); // Null field omitted
assertThat(json).doesNotContain("targetAtv");
}
@Test
void testCampaignRuleSummaryDtoMappingAndSerialization() throws Exception {
CampaignRule rule = new CampaignRule();
rule.setRuleId("R01");
rule.setRuleName("Bonus Point Rule");
rule.setRuleType(RuleType.AWARD);
rule.setCampaignId(new ReferenceData("CMP01", "Happy Birthday Campaign"));
rule.setPoolId(new ReferenceData("POOL01", "Main Point Pool"));
rule.setEffectiveFrom(LocalDateTime.of(2026, 1, 1, 0, 0));
CampaignRuleSummaryDto dto = campaignRuleService.toSummaryDto(rule);
String json = objectMapper.writeValueAsString(dto);
assertThat(json).contains("\"ruleId\":\"R01\"");
assertThat(json).contains("\"ruleName\":\"Bonus Point Rule\"");
assertThat(json).contains("\"ruleType\":\"AWD\"");
assertThat(json).contains("\"code\":\"CMP01\"");
assertThat(json).doesNotContain("effectiveTo"); // Null field omitted
assertThat(json).doesNotContain("campaignFormulaOne");
assertThat(json).doesNotContain("campaignFormulaSetting");
}
@Test
void testSanitizeFormulasInCampaignRule() throws Exception {
CampaignRule rule = new CampaignRule();
rule.setRuleId("R02");
rule.setRuleName("Double Points");
// Formulas with all nulls
rule.setCampaignFormulaOne(new dev.sonpx.loyalty.mcp.reward.model.CampaignFormulaOne());
rule.setCampaignFormulaTwo(new dev.sonpx.loyalty.mcp.reward.model.CampaignFormulaTwo());
campaignRuleService.sanitizeFormulas(rule);
assertThat(rule.getCampaignFormulaOne()).isNull();
assertThat(rule.getCampaignFormulaTwo()).isNull();
String json = objectMapper.writeValueAsString(rule);
assertThat(json).doesNotContain("campaignFormulaOne");
assertThat(json).doesNotContain("campaignFormulaTwo");
}
}

View File

@@ -0,0 +1,19 @@
package dev.sonpx.loyalty.mcp.config;
import org.junit.jupiter.api.Test;
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerSseProperties;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
class McpServerKeepAliveTest {
@Test
void testKeepAlivePropertiesSetting() {
McpServerSseProperties properties = new McpServerSseProperties();
properties.setKeepAliveInterval(Duration.ofSeconds(30));
assertThat(properties.getKeepAliveInterval()).isEqualTo(Duration.ofSeconds(30));
}
}