feat(wiki): add KB-scoped pageType profile table and page metadata columns

This commit is contained in:
matevip 2026-05-31 07:54:06 +08:00
parent ec47d19bf8
commit 368797e619
6 changed files with 281 additions and 0 deletions

View File

@ -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;

View File

@ -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}.
*
* <p>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;
}

View File

@ -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<WikiPageTypeProfileEntity> {
}

View File

@ -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;

View File

@ -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;

View File

@ -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
.<WikiPageTypeProfileEntity>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
}
}