diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java index 076d99de..cc92549b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageEntity.java @@ -49,6 +49,28 @@ public class WikiPageEntity { /** Page type: entity / concept / source / synthesis */ private String pageType; + /** + * Structured pageType metadata (schema-validated fields) as a JSON object. + * Stored as a blob rather than exploded into per-field columns so each KB + * can define its own schema without altering the table. Written with the + * full page save path; partial column updates must avoid touching it. + */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String metadataJson; + + /** Last metadata validation outcome: {@code ok} / {@code warning} / {@code invalid}. */ + private String metadataValidationStatus; + + /** Metadata validation warnings/errors as a JSON array (field, reason, source, rawValuePreview). */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String metadataValidationJson; + + /** Template key used when generating this page, when applicable. */ + private String templateKey; + + /** Profile version in effect when the page was generated or last validated. */ + private Integer profileVersion; + /** Purpose hint for LLM ingest routing */ private String purposeHint; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageTypeProfileEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageTypeProfileEntity.java new file mode 100644 index 00000000..c6fe4b58 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiPageTypeProfileEntity.java @@ -0,0 +1,54 @@ +package vip.mate.wiki.model; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * A KB-scoped pageType profile: the set of page types a knowledge base + * recognises, plus each type's field schema, per-stage LLM instructions and + * Markdown template, serialized into {@link #configJson}. + * + *

The built-in default profile is provided as a code constant and is NOT + * stored in this table, so {@link #kbId} is always non-null. A virtual + * generated column on the table enforces at most one enabled profile per KB. + * + * @author MateClaw Team + */ +@Data +@TableName("mate_wiki_page_type_profile") +public class WikiPageTypeProfileEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** Owning knowledge base (never null — built-in default is not stored). */ + private Long kbId; + + /** Profile name, e.g. {@code default}, {@code regulation}. */ + private String name; + + /** Profile version, bumped on each saved edit. */ + private Integer version; + + /** Full pageType configuration as JSON. */ + private String configJson; + + /** {@code 1} = the active profile for the KB. */ + private Integer enabled; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageTypeProfileMapper.java b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageTypeProfileMapper.java new file mode 100644 index 00000000..7dcfa43c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/repository/WikiPageTypeProfileMapper.java @@ -0,0 +1,14 @@ +package vip.mate.wiki.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.wiki.model.WikiPageTypeProfileEntity; + +/** + * Mapper for {@link WikiPageTypeProfileEntity}. + * + * @author MateClaw Team + */ +@Mapper +public interface WikiPageTypeProfileMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V134__wiki_page_type_profile.sql b/mateclaw-server/src/main/resources/db/migration/h2/V134__wiki_page_type_profile.sql new file mode 100644 index 00000000..5f2d55b2 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V134__wiki_page_type_profile.sql @@ -0,0 +1,43 @@ +-- V134: KB-scoped pageType profile + structured page metadata columns. +-- +-- A profile holds a KB's pageType definitions (field schema, per-stage LLM +-- instructions, Markdown template) as config_json. The built-in default +-- profile is NOT stored here — it lives as a code constant — so kb_id is +-- NOT NULL and every stored row belongs to a concrete KB. +-- +-- "At most one enabled profile per KB" is enforced at the DB level via a +-- virtual generated column that yields kb_id only for the live-enabled +-- subset (NULL otherwise) plus a plain UNIQUE constraint; NULLs are +-- non-comparable under UNIQUE, so disabled/deleted rows coexist. This avoids +-- a service-layer check-then-insert race across horizontally-scaled nodes. + +CREATE TABLE IF NOT EXISTS mate_wiki_page_type_profile ( + id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + version INT NOT NULL DEFAULT 1, + config_json CLOB NOT NULL, + enabled TINYINT NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0, + -- Yields kb_id only for the live-enabled row; NULL otherwise. The UNIQUE + -- constraint below then permits at most one enabled profile per KB. + enabled_kb BIGINT GENERATED ALWAYS AS ( + CASE WHEN enabled = 1 AND deleted = 0 THEN kb_id ELSE NULL END + ), + PRIMARY KEY (id), + CONSTRAINT uk_wiki_ptprofile_name UNIQUE (kb_id, name, deleted), + CONSTRAINT uk_wiki_ptprofile_enabled UNIQUE (enabled_kb) +); +CREATE INDEX IF NOT EXISTS idx_wiki_ptprofile_kb + ON mate_wiki_page_type_profile (kb_id, enabled, deleted); + +-- Structured page metadata: schema-validated pageType fields live in +-- metadata_json (not exploded into columns); validation status/details and +-- the generating profile/template are recorded alongside. +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS metadata_json CLOB; +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS metadata_validation_status VARCHAR(32); +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS metadata_validation_json CLOB; +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS template_key VARCHAR(128); +ALTER TABLE mate_wiki_page ADD COLUMN IF NOT EXISTS profile_version INT; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V134__wiki_page_type_profile.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V134__wiki_page_type_profile.sql new file mode 100644 index 00000000..f4f018ab --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V134__wiki_page_type_profile.sql @@ -0,0 +1,63 @@ +-- V134: KB-scoped pageType profile + structured page metadata columns. +-- See the H2 file for the design rationale. MySQL 8 uses a VIRTUAL generated +-- column for the "one enabled profile per KB" constraint, and an +-- INFORMATION_SCHEMA guard for each idempotent ADD COLUMN (MySQL has no +-- ADD COLUMN IF NOT EXISTS). + +CREATE TABLE IF NOT EXISTS mate_wiki_page_type_profile ( + id BIGINT NOT NULL, + kb_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + version INT NOT NULL DEFAULT 1, + config_json LONGTEXT NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted INT NOT NULL DEFAULT 0, + -- Yields kb_id only for the live-enabled row; NULL otherwise. InnoDB + -- ignores NULL keys for uniqueness, giving "at most one enabled per KB". + enabled_kb BIGINT + GENERATED ALWAYS AS ( + CASE WHEN enabled = 1 AND deleted = 0 THEN kb_id ELSE NULL END + ) VIRTUAL, + PRIMARY KEY (id), + UNIQUE KEY uk_wiki_ptprofile_name (kb_id, name, deleted), + UNIQUE KEY uk_wiki_ptprofile_enabled (enabled_kb), + KEY idx_wiki_ptprofile_kb (kb_id, enabled, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Structured page metadata columns (idempotent adds). +SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'metadata_json'); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN metadata_json LONGTEXT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'metadata_validation_status'); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN metadata_validation_status VARCHAR(32)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'metadata_validation_json'); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN metadata_validation_json LONGTEXT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'template_key'); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN template_key VARCHAR(128)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_page' + AND COLUMN_NAME = 'profile_version'); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_wiki_page ADD COLUMN profile_version INT', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiPageTypeProfileMapperE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiPageTypeProfileMapperE2ETest.java new file mode 100644 index 00000000..5d070b2a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/repository/WikiPageTypeProfileMapperE2ETest.java @@ -0,0 +1,85 @@ +package vip.mate.wiki.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import vip.mate.wiki.model.WikiPageTypeProfileEntity; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Validates the DB-level invariants of the pageType profile table against H2: + * the V134 generated-column UNIQUE permits at most one enabled profile per KB, + * while disabled rows coexist freely. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999" + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class WikiPageTypeProfileMapperE2ETest { + + @Autowired + private WikiPageTypeProfileMapper mapper; + + private WikiPageTypeProfileEntity profile(long kbId, String name, int enabled) { + WikiPageTypeProfileEntity p = new WikiPageTypeProfileEntity(); + p.setKbId(kbId); + p.setName(name); + p.setVersion(1); + p.setConfigJson("{\"version\":1,\"pageTypes\":{}}"); + p.setEnabled(enabled); + p.setCreateTime(LocalDateTime.now()); + p.setUpdateTime(LocalDateTime.now()); + return p; + } + + @Test + void insertsAndReadsBack() { + WikiPageTypeProfileEntity p = profile(9001L, "default", 1); + mapper.insert(p); + assertNotNull(p.getId()); + WikiPageTypeProfileEntity loaded = mapper.selectById(p.getId()); + assertEquals("default", loaded.getName()); + assertEquals(9001L, loaded.getKbId()); + } + + @Test + void secondEnabledProfileForSameKb_isRejected() { + mapper.insert(profile(9002L, "default", 1)); + // A different name but also enabled for the same KB must violate the + // generated-column UNIQUE (one enabled profile per KB). + assertThrows(Exception.class, () -> mapper.insert(profile(9002L, "regulation", 1))); + } + + @Test + void multipleDisabledProfilesForSameKb_coexist() { + mapper.insert(profile(9003L, "default", 1)); + // enabled=0 rows yield NULL in the generated column and are exempt from + // the unique check, so several may coexist. + mapper.insert(profile(9003L, "draft-a", 0)); + mapper.insert(profile(9003L, "draft-b", 0)); + long count = mapper.selectCount( + com.baomidou.mybatisplus.core.toolkit.Wrappers + .lambdaQuery() + .eq(WikiPageTypeProfileEntity::getKbId, 9003L)); + assertEquals(3, count); + } + + @Test + void enabledProfilesInDifferentKbs_coexist() { + mapper.insert(profile(9101L, "default", 1)); + mapper.insert(profile(9102L, "default", 1)); + assertTrue(true); // no exception thrown — different KBs are independent + } +}