fix(sync): align public change stream schema

This commit is contained in:
jiashumeng
2026-09-15 18:49:27 +09:30
parent 4cd95c61ab
commit e3b6edeb7b
9 changed files with 180 additions and 81 deletions
@@ -15,7 +15,7 @@
## 2. 当前结论
当前状态:**协议可连接,业务契约未通过,不能用于真实销售合同到期推送。**
当前状态:**协议可连接,公开字段Schema已在本地修正并通过回归;可靠同步与服务身份仍未通过,不能用于真实销售合同到期推送。**
已经具备:
@@ -29,24 +29,27 @@
## 3. 已确认问题与处理顺序
### P0-1 服务身份没有绑定真实源租户
### P0-1 服务身份与源数据域语义混淆
现状:`integration.context.get.tenant_id`由作用域编码拼接得到,`sync`被当成租户;服务Token又按`serviceName + scope`全局复用,创建它的Open Client身份没有进入Token。作用域代表“能用什么工具”,不是“属于哪个租户/数据域”。
代码核对结论:当前SaaS业务库没有统一的`tenant_id`字段。人员、物业、合同处于同一个业务数据域,在线行权限由代理商、物业和销售关系计算;合同负责人由`contract_new.sale_id`表达。不能为了对接新增一个虚假的行级租户字段。
影响:不能证明某个小趣租户连接的是哪个SaaS数据域,也不能可靠阻止不同接入方复用同一服务身份。
现状仍有两个独立问题:`integration.context.get.tenant_id`由作用域编码拼接得到,`sync`被误当成数据域;服务Token又按`serviceName + scope`全局复用,创建它的Open Client身份没有进入Token。作用域只代表“能用什么工具”,不是数据来源或调用方身份。
影响:消费者无法稳定识别数据来自哪套SaaS实例;不同Open Client使用相同`serviceName`时可能复用服务Token。它不会替代合同负责人分发,但会影响连接隔离、吊销和审计。
标准改造:
1. 为Open Client建立不可变的`source_tenant_id`(或等价数据域ID)绑定,不从请求体、scope或名称推断。
2. 服务Token持久化`open_client_id + source_tenant_id + subject_name`,ensure按该组合幂等签发。
3. `integration.context.get.tenant_id`返回真实`source_tenant_id`;`subject_id`返回稳定服务主体ID。
4. 每条SYNC查询都从鉴权上下文取得该数据域并强制过滤;若当前源库确实只有单一数据域,也必须以明确配置和测试证明,不能用“user为空即查全库”表达。
1. 为每套MCP部署配置稳定的`source_domain_id`,例如生产SaaS数据域标识;它来自服务端配置,不由请求体、scope或名称推断。
2. 服务Token持久化`open_client_id + subject_name`,ensure按调用方、服务主体和scope组合幂等签发;不同Open Client不共享Token。
3. `integration.context.get`返回稳定`source_domain_id`。若为保持既有契约继续输出`tenant_id`,必须明确该字段在当前单数据域SaaS中表示“源数据域”,不能再返回`sync`。
4. SYNC服务Token读取该单一数据域的完整变化流;记录按合同`owner_user_id`分发。在线个人查询继续使用现有人员ACL,两条通道不混用。
5. 小趣AI的租户连接负责把自己的`tenant_id`显式绑定到该`source_domain_id`,禁止同一个源数据域被误配给多个目标租户。
验收:两个Open Client即使使用相同`serviceName=crm`也得到不同服务Token和租户上下文;跨租户数据为零;吊销其中一个不影响另一个。
验收:两个Open Client即使使用相同`serviceName=crm`也得到不同服务Token;吊销其中一个不影响另一个;上下文返回稳定源数据域;小趣AI连接错配源数据域时在同步前失败关闭。
### P0-2 三条变化流不符合冻结字段Schema
### P0-2 三条变化流公开字段Schema(本地已修正,待部署联调)
现状与冻结契约的差异:
原实现与冻结契约的差异:
| 流 | 当前输出 | 必须输出 |
|---|---|---|
@@ -54,9 +57,9 @@
| properties | 数值型`property_id` | 字符串型稳定ID;状态使用公开枚举 |
| contracts | 数值型`contract_id/property_id/owner_user_id`、数值状态`0..4`、数值`expire_remind`,缺`remind_days` | 字符串型稳定ID、`PENDING/REJECTED/ACTIVE/EXPIRED/TERMINATED/VOID`、布尔提醒标记、实际提醒天数 |
影响:小趣Gateway的严格Schema校验会正确拒绝当前users和contracts数据;合同不会进入镜像、规则和推送链路。
已完成:新增独立公开DTO,将三类稳定ID统一为字符串;`mobile`改为`verified_mobile`;合同状态转换为公开枚举;`expire_remind`转换为布尔值;补齐`remind_days`;变化页使用统一`stream`字段。未知合同状态按`DATA_INTEGRITY_ERROR`失败关闭,不伪造业务语义。
标准改造:在MCP边界使用独立DTO/枚举映射,将老库字段一次性转换成公开Schema;公开契约不得直接泄漏数据库整数状态和Java实体类型。没有权威来源的可选字段保持空,不伪造。
验证:Java编译和MCP服务全量测试通过,`220`项测试、`0`失败、`0`错误。尚需部署后的真实数据样例和小趣Gateway严格消费者契约联调,完成前仍不算生产通过。
验收:用真实样例对三条流执行消费者契约测试;Gateway无需兼容代码即可全部通过,未知字段、未知状态和错误类型必须失败。
@@ -82,23 +85,23 @@
### P0-5 数据权限版本不能覆盖真实权限变化
现状:`data_scope_version`仅取`token_scope`关系的修改时间;Open Client租户绑定、scope-tool变化、源数据域ACL变化不会稳定反映。
现状:`data_scope_version`仅取`token_scope`关系的修改时间;Open Client身份关联、scope-tool变化、源数据域ACL变化不会稳定反映。
影响:权限变化后旧同步位点可能继续使用,消费者无法判断需要重建或停止派发。
标准改造:维护数据域授权版本,任何租户绑定、工具授权、行级ACL语义变更都原子递增;上下文与同步Token使用同一个版本。
标准改造:维护数据域授权版本,任何调用方身份关联、工具授权、行级ACL语义变更都原子递增;上下文与同步Token使用同一个版本。
验收:修改任一影响可见数据的授权后,旧轮立即失败关闭,新轮在新数据域版本下重建。
## 4. 依赖顺序
1. 冻结真实`source_tenant_id`来源、数据域范围和合同状态/提醒字段字典。
2. 完成服务身份与源租户持久化模型,补迁移、后台配置、ensure和上下文测试。
1. 冻结服务端`source_domain_id`配置、单数据域范围和合同状态/提醒字段字典。
2. 完成Open Client与服务Token身份关联,补迁移、ensure和上下文测试。
3. 建立通用变更日志/Outbox与保留策略,再迁移三条变化流;不继续强化活表分页方案。
4. 使用独立公开DTO完成三条流字段映射和消费者契约测试。
5. 修正交接Token生命周期与统一数据权限版本。
6. 在测试环境用真实用户、物业、合同及变更/删除数据完成MCP侧验收。
7. 交付Open Client、真实源租户ID和凭证给小趣AI侧,联合验证“同步→映射→规则→事项→展示→推送前复核”。
7. 交付Open Client、稳定源数据域ID和凭证给小趣AI侧,联合验证“同步→映射→规则→事项→展示→推送前复核”。
## 5. 状态看板
@@ -106,8 +109,8 @@
|---|---|---|---|
| MCP协议与工具发现 | 已完成 | MCP | 保持兼容并纳入回归 |
| 服务密钥与Token基础生命周期 | 已完成 | MCP | 在租户模型完成后升级幂等键 |
| 真实源租户与服务主体隔离 | 未完成,P0阻塞 | MCP | 明确权威租户来源并设计迁移 |
| 三流公开字段Schema | 未完成,P0阻塞 | MCP | 建立DTO和状态字典,跑消费者契约测试 |
| 源数据域标识与服务主体隔离 | 未完成,P0阻塞 | MCP | 增加稳定部署数据域标识和Open Client关联 |
| 三流公开字段Schema | 本地代码完成,待部署联调 | MCP | 部署后用真实三流样例跑Gateway严格契约测试 |
| 可靠变化日志与稳定快照 | 未完成,P0阻塞 | MCP | 设计Outbox/CDC及保留策略 |
| 权限版本与交接位点生命周期 | 未完成,P0阻塞 | MCP | 与租户/变化日志模型一起实现 |
| 小趣AI连接与消费代码 | 已完成,待部署联调 | 小趣AI | 见小趣AI仓库`PROJECT.md` |
@@ -117,9 +120,9 @@
以下条件全部满足前,不得把MCP同步标记为生产可用,也不得向真实销售发送合同提醒:
- 真实源租户隔离、服务主体隔离和跨租户反向用例通过。
- 稳定源数据域、服务主体隔离和连接错配反向用例通过。
- 三条变化流通过冻结Schema及真实数据样例校验。
- 并发写入、删除、断点续传、超过24小时续传和权限变化测试通过。
- 凭证不入库明文、不入日志;轮换和吊销测试通过。
- 小趣AI侧完成负责人、客户双映射和发送前实时复核。
- 完整链路具有`request_id/snapshot_id/source_tenant_id`审计证据,可定位每条事项和推送的数据来源。
- 完整链路具有`request_id/snapshot_id/source_domain_id`审计证据,可定位每条事项和推送的数据来源。
@@ -121,7 +121,7 @@ public class ChangesEngine {
// 4. 组装响应:中间页回传锚点 + next_cursor;最后一页签发 next_sync_token
String roundToken = codec.encodeRound(entityName, fromTs, cutoffTs, snapshotId, scopeVersion);
Map<String, Object> result = new LinkedHashMap<>();
result.put("entity", entityName);
result.put("stream", entityName);
result.put("snapshot_id", snapshotId);
result.put("data_cutoff_at", iso(new Date(cutoffTs * 1000)));
result.put("limit", pageSize);
@@ -6,6 +6,8 @@ import me.iiv.agent.entity.m0.User;
import me.iiv.agent.entity.m2.ContractNew;
import me.iiv.agent.mapper.m0.ProperytMapper;
import me.iiv.agent.mapper.m2.ContractNewMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.permission.PermissionService;
import org.springframework.stereotype.Component;
@@ -77,22 +79,14 @@ public class ContractsSyncEntity extends AbstractSyncEntity implements SyncEntit
private Map<String, Object> toItem(ContractNew row, long mt, Map<Long, String> customerIds) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
Map<String, Object> item = new LinkedHashMap<>();
item.put("contract_id", row.getId());
item.put("contract_no", row.getContractNo());
item.put("contract_name", row.getContractName());
item.put("status", row.getContractStatus());
item.put("start_at", ChangesEngine.iso(row.getStartDate()));
item.put("end_at", ChangesEngine.iso(row.getEndDate()));
item.put("amount_minor", toMinor(row.getAmount()));
item.put("customer_id", row.getPropertyId() == null ? null : customerIds.get(row.getPropertyId()));
item.put("property_id", row.getPropertyId());
item.put("owner_user_id", row.getSaleId());
item.put("expire_remind", row.getExpireRemind());
item.put("updated_at", ChangesEngine.iso(effective));
item.put("version", mt);
item.put("deleted_at", deleted ? ChangesEngine.iso(effective) : null);
return item;
return SyncChangeSchemas.toMap(new SyncChangeSchemas.ContractChange(
stableId(row.getId()), row.getContractNo(), row.getContractName(),
publicStatus(row.getContractStatus()), ChangesEngine.iso(row.getStartDate()),
ChangesEngine.iso(row.getEndDate()), toMinor(row.getAmount()),
row.getPropertyId() == null ? null : customerIds.get(row.getPropertyId()),
stableId(row.getPropertyId()), stableId(row.getSaleId()),
Integer.valueOf(1).equals(row.getExpireRemind()), row.getRemindDays(),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
/** 元 → 分(amount 为 decimal(14,2),精确换算);null 原样返回 null。 */
@@ -100,6 +94,30 @@ public class ContractsSyncEntity extends AbstractSyncEntity implements SyncEntit
return amount == null ? null : amount.movePointRight(2).longValue();
}
/** Legacy database status code to the stable public contract enum. */
static String publicStatus(Integer status) {
if (status == null) {
throw invalidStatus(null);
}
return switch (status) {
case 0 -> "PENDING";
case 1 -> "REJECTED";
case 2 -> "ACTIVE";
case 3 -> "EXPIRED";
case 4 -> "TERMINATED";
default -> throw invalidStatus(status);
};
}
private static ToolCallException invalidStatus(Integer status) {
return new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR,
"合同状态无法映射到公开契约: " + status);
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
/**
* property_id → customerId 映射。properyt.customerId 存在 NULL 行(2026-09-14 dev 冒烟命中),
* Collectors.toMap 遇 null 值抛 NPE,必须用 put 逐条装填。
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -59,17 +58,13 @@ public class PropertiesSyncEntity extends AbstractSyncEntity implements SyncEnti
private Map<String, Object> toItem(Properyt row, long mt) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
Map<String, Object> item = new LinkedHashMap<>();
item.put("property_id", row.getId());
item.put("customer_id", row.getCustomerId());
item.put("property_name", row.getName());
item.put("customer_name", row.getJinideName());
item.put("unified_social_credit_code", row.getIdentificationNumber());
item.put("external_customer_no", row.getSerialnumber());
item.put("status", deleted ? "inactive" : "active");
item.put("updated_at", ChangesEngine.iso(effective));
item.put("version", mt);
item.put("deleted_at", deleted ? ChangesEngine.iso(effective) : null);
return item;
return SyncChangeSchemas.toMap(new SyncChangeSchemas.PropertyChange(
stableId(row.getId()), row.getCustomerId(), row.getName(), row.getJinideName(),
row.getIdentificationNumber(), row.getSerialnumber(), deleted ? "INACTIVE" : "ACTIVE",
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
}
@@ -0,0 +1,73 @@
package me.iiv.agent.mcp.sync;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
/**
* Public schemas for the three change streams.
*
* <p>The source tables are legacy persistence models with numeric identifiers,
* integer flags and database-specific status codes. These DTOs form the stable
* MCP boundary so persistence details cannot leak into consumers.</p>
*/
final class SyncChangeSchemas {
private static final ObjectMapper MAPPER = new ObjectMapper()
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { };
private SyncChangeSchemas() {
}
static Map<String, Object> toMap(Object value) {
return MAPPER.convertValue(value, MAP_TYPE);
}
record UserChange(
@JsonProperty("user_id") String userId,
@JsonProperty("display_name") String displayName,
String status,
@JsonProperty("employee_no") String employeeNo,
@JsonProperty("verified_mobile") String verifiedMobile,
@JsonProperty("organization_ids") List<String> organizationIds,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
record PropertyChange(
@JsonProperty("property_id") String propertyId,
@JsonProperty("customer_id") String customerId,
@JsonProperty("property_name") String propertyName,
@JsonProperty("customer_name") String customerName,
@JsonProperty("unified_social_credit_code") String unifiedSocialCreditCode,
@JsonProperty("external_customer_no") String externalCustomerNo,
String status,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
record ContractChange(
@JsonProperty("contract_id") String contractId,
@JsonProperty("contract_no") String contractNo,
@JsonProperty("contract_name") String contractName,
String status,
@JsonProperty("start_at") String startAt,
@JsonProperty("end_at") String endAt,
@JsonProperty("amount_minor") Long amountMinor,
@JsonProperty("customer_id") String customerId,
@JsonProperty("property_id") String propertyId,
@JsonProperty("owner_user_id") String ownerUserId,
@JsonProperty("expire_remind") boolean expireRemind,
@JsonProperty("remind_days") Integer remindDays,
@JsonProperty("updated_at") String updatedAt,
long version,
@JsonProperty("deleted_at") String deletedAt) {
}
}
@@ -7,14 +7,13 @@ import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* users 同步实体(m0.user):人员主数据增量。
* 字段口径(2026-09-14 字段核对):status 对齐 soft_delete(在职状态不用 work_status,那是考勤);
* mobile 即唯一手机号字段(verified_mobile 无对应列,按决策砍掉);organization_ids 取 dept_id;
* verified_mobile 映射唯一手机号字段 mobile;organization_ids 取 dept_id;
* 无人员合并机制,merged_into_user_id 省略。users 表无行级过滤先例,SYNC 工具以 scope 白名单为闸。
*/
@Component
@@ -50,16 +49,13 @@ public class UsersSyncEntity extends AbstractSyncEntity implements SyncEntity {
private Map<String, Object> toItem(User row, long mt) {
boolean deleted = row.getSoftDelete() != null && row.getSoftDelete() == 1;
Date effective = row.getModifyTime() != null ? row.getModifyTime() : row.getCreateTime();
Map<String, Object> item = new LinkedHashMap<>();
item.put("user_id", row.getId());
item.put("display_name", row.getName());
item.put("status", deleted ? "inactive" : "active");
item.put("employee_no", row.getNo());
item.put("mobile", row.getMobile());
item.put("organization_ids", row.getDeptId() == null ? List.of() : List.of(row.getDeptId()));
item.put("updated_at", ChangesEngine.iso(effective));
item.put("version", mt);
item.put("deleted_at", deleted ? ChangesEngine.iso(effective) : null);
return item;
return SyncChangeSchemas.toMap(new SyncChangeSchemas.UserChange(
stableId(row.getId()), row.getName(), deleted ? "INACTIVE" : "ACTIVE", row.getNo(),
row.getMobile(), row.getDeptId() == null ? List.of() : List.of(stableId(row.getDeptId())),
ChangesEngine.iso(effective), mt, deleted ? ChangesEngine.iso(effective) : null));
}
private static String stableId(Long id) {
return id == null ? null : id.toString();
}
}
@@ -43,7 +43,7 @@ public class ChangesTools implements ToolObject {
@XqTool(category = "sync", kind = "read")
@Tool(name = "users.changes.list", description = "增量同步人员主数据(m0.user)。" + PROTOCOL_DESC
+ "items 字段:user_id/display_name/status(active=在职 inactive=删除,对齐软删标记)/"
+ "employee_no/mobile/organization_ids(部门 id 列表)/updated_at(带时区)/version/deleted_at。")
+ "employee_no/verified_mobile/organization_ids(部门 id 列表)/updated_at(带时区)/version/deleted_at。")
public Map<String, Object> usersChangesList(
@ToolParam(required = false, description = SYNC_TOKEN_DESC) String syncToken,
@ToolParam(required = false, description = CURSOR_DESC) String cursor,
@@ -65,9 +65,9 @@ public class ChangesTools implements ToolObject {
@XqTool(category = "sync", kind = "read")
@Tool(name = "contracts.changes.list", description = "增量同步合同主数据(m2.contract_new)。" + PROTOCOL_DESC
+ "items 字段:contract_id/contract_no/contract_name/status(0=待审 1=驳回 2=正常 3=过期 4=终止)/"
+ "items 字段:contract_id/contract_no/contract_name/status(PENDING/REJECTED/ACTIVE/EXPIRED/TERMINATED)/"
+ "start_at/end_at(带时区)/amount_minor(金额,分)/customer_id/property_id/"
+ "owner_user_id(负责人稳定 user.id)/expire_remind/updated_at/version/deleted_at。"
+ "owner_user_id(负责人稳定 user.id)/expire_remind/remind_days/updated_at/version/deleted_at。"
+ "行级权限跟随 Token 绑定的人(与 contract.list.query 同一 ACL)。")
public Map<String, Object> contractsChangesList(
@ToolParam(required = false, description = SYNC_TOKEN_DESC) String syncToken,
@@ -94,7 +94,7 @@ class ChangesEngineTest {
Map<String, Object> result = engine.execute("users", null, null, null);
assertEquals("users", result.get("entity"));
assertEquals("users", result.get("stream"));
assertNotNull(result.get("snapshot_id"));
assertNotNull(result.get("data_cutoff_at"));
assertEquals(2, result.get("count"));
@@ -9,6 +9,8 @@ import me.iiv.agent.entity.m2.ContractNew;
import me.iiv.agent.mapper.m0.ProperytMapper;
import me.iiv.agent.mapper.m0.UserMapper;
import me.iiv.agent.mapper.m2.ContractNewMapper;
import me.iiv.agent.mcp.McpErrorCode;
import me.iiv.agent.mcp.ToolCallException;
import me.iiv.agent.permission.PermissionService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeEach;
@@ -24,6 +26,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
@@ -79,19 +82,19 @@ class SyncEntitiesTest {
assertTrue(sql.contains("COALESCE(modify_time, create_time)"), "窗口必须按生效修改时间过滤: " + sql);
assertEquals(2, rows.size());
assertEquals(1L, rows.get(0).item().get("user_id"));
assertEquals("1", rows.get(0).item().get("user_id"));
assertEquals("张三", rows.get(0).item().get("display_name"));
assertEquals("active", rows.get(0).item().get("status"));
assertEquals("ACTIVE", rows.get(0).item().get("status"));
assertEquals("E001", rows.get(0).item().get("employee_no"));
assertEquals("13800000000", rows.get(0).item().get("mobile"));
assertEquals(List.of(88L), rows.get(0).item().get("organization_ids"));
assertEquals("13800000000", rows.get(0).item().get("verified_mobile"));
assertEquals(List.of("88"), rows.get(0).item().get("organization_ids"));
assertEquals("2026-09-01T10:00:00+08:00", rows.get(0).item().get("updated_at"));
assertEquals(Timestamp.valueOf("2026-09-01 10:00:00").getTime() / 1000,
rows.get(0).item().get("version"));
assertNull(rows.get(0).item().get("deleted_at"));
// tombstone:软删行 deleted_at 非空、status=inactive
assertEquals("inactive", rows.get(1).item().get("status"));
assertEquals("INACTIVE", rows.get(1).item().get("status"));
assertEquals("2026-09-02T10:00:00+08:00", rows.get(1).item().get("deleted_at"));
assertEquals(List.of(), rows.get(1).item().get("organization_ids"), "无部门时为空列表");
}
@@ -131,13 +134,13 @@ class SyncEntitiesTest {
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyPropertyRowFilter(any(), any());
assertEquals(9L, rows.get(0).item().get("property_id"));
assertEquals("9", rows.get(0).item().get("property_id"));
assertEquals("KD10086", rows.get(0).item().get("customer_id"));
assertEquals("望京SOHO", rows.get(0).item().get("property_name"));
assertEquals("金蝶客户名称", rows.get(0).item().get("customer_name"));
assertEquals("91110105XXXXXXXX", rows.get(0).item().get("unified_social_credit_code"));
assertEquals("452542", rows.get(0).item().get("external_customer_no"));
assertEquals("active", rows.get(0).item().get("status"));
assertEquals("ACTIVE", rows.get(0).item().get("status"));
}
@Test
@@ -156,6 +159,7 @@ class SyncEntitiesTest {
ContractsSyncEntity entity = new ContractsSyncEntity(contractNewMapper, properytMapper, permissionService);
ContractNew contract = new ContractNew();
contract.setId(10L);
contract.setContractStatus(2);
contract.setPropertyId(2L);
contract.setSoftDelete(0);
contract.setModifyTime(Timestamp.valueOf("2026-09-01 13:54:21"));
@@ -182,6 +186,7 @@ class SyncEntitiesTest {
contract.setPropertyId(1L);
contract.setSaleId(1001L);
contract.setExpireRemind(1);
contract.setRemindDays(90);
contract.setAmount(new BigDecimal("120000.00"));
contract.setStartDate(java.sql.Date.valueOf("2026-01-01"));
contract.setEndDate(java.sql.Date.valueOf("2026-12-31"));
@@ -198,18 +203,27 @@ class SyncEntitiesTest {
List<SyncRow> rows = entity.fetchPage(null, 2000L, null, null, 3, user);
verify(permissionService).applyContractRowFilter(any(), any());
assertEquals(9L, rows.get(0).item().get("contract_id"));
assertEquals("9", rows.get(0).item().get("contract_id"));
assertEquals("452542C001", rows.get(0).item().get("contract_no"));
assertEquals(2, rows.get(0).item().get("status"));
assertEquals("ACTIVE", rows.get(0).item().get("status"));
assertEquals("2026-01-01T00:00:00+08:00", rows.get(0).item().get("start_at"));
assertEquals("2026-12-31T00:00:00+08:00", rows.get(0).item().get("end_at"));
assertEquals(12000000L, rows.get(0).item().get("amount_minor"), "金额按分输出");
assertEquals("KD10086", rows.get(0).item().get("customer_id"));
assertEquals(1L, rows.get(0).item().get("property_id"));
assertEquals(1001L, rows.get(0).item().get("owner_user_id"), "负责人=稳定 user.id");
assertEquals(1, rows.get(0).item().get("expire_remind"));
assertEquals("1", rows.get(0).item().get("property_id"));
assertEquals("1001", rows.get(0).item().get("owner_user_id"), "负责人=稳定 user.id");
assertEquals(true, rows.get(0).item().get("expire_remind"));
assertEquals(90, rows.get(0).item().get("remind_days"));
assertEquals(Timestamp.valueOf("2026-09-01 13:54:21").getTime() / 1000,
rows.get(0).item().get("version"));
assertEquals("2026-09-01T13:54:21+08:00", rows.get(0).item().get("deleted_at"), "软删行返回 tombstone");
}
@Test
void contractsRejectUnknownLegacyStatus() {
ToolCallException error = assertThrows(ToolCallException.class,
() -> ContractsSyncEntity.publicStatus(99));
assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, error.getCode());
}
}