package com.lingyue.document.entity; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import com.lingyue.common.domain.entity.SimpleModel; import com.lingyue.common.mybatis.PostgreSqlJsonbTypeHandler; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import java.util.List; /** * 文档块实体(参考飞书 Block 设计) * 表示文档中的一个结构化内容块(段落、标题、表格、列表等) * * 核心设计: * - 块内容由多个 TextElement 组成,实体作为元素嵌入,而非通过字符偏移定位 * - 这样编辑时只需修改对应元素,不会导致其他元素位置失效 * * @author lingyue * @since 2026-01-21 */ @EqualsAndHashCode(callSuper = true) @Data @TableName(value = "document_blocks", autoResultMap = true) @Schema(description = "文档块实体") public class DocumentBlock extends SimpleModel { @Schema(description = "文档ID") @TableField("document_id") private String documentId; @Schema(description = "父块ID(支持嵌套结构)") @TableField("parent_id") private String parentId; @Schema(description = "子块ID列表") @TableField(value = "children", typeHandler = PostgreSqlJsonbTypeHandler.class) private List children; @Schema(description = "块序号(在同级中的顺序)") @TableField("block_index") private Integer blockIndex; @Schema(description = "块类型", example = "page/heading1/heading2/text/bullet/ordered/table/image/code/quote") @TableField("block_type") private String blockType; @Schema(description = "块内元素列表(TextElement数组)", example = "[{\"type\":\"text_run\",\"content\":\"Hello\"},{\"type\":\"entity\",\"entityId\":\"xxx\"}]") @TableField(value = "elements", typeHandler = PostgreSqlJsonbTypeHandler.class) private List elements; @Schema(description = "块样式") @TableField(value = "style", typeHandler = PostgreSqlJsonbTypeHandler.class) private Object style; @Schema(description = "块元数据") @TableField(value = "metadata", typeHandler = PostgreSqlJsonbTypeHandler.class) private Object metadata; /** * 文本元素(内嵌类) * 参考飞书的 TextElement 设计 */ @Data @Schema(description = "文本元素") public static class TextElement { @Schema(description = "元素类型", example = "text_run/entity/mention_doc/link/equation") private String type; // ===== text_run 类型 ===== @Schema(description = "文本内容(type=text_run时)") private String content; @Schema(description = "文本样式") private TextStyle style; // ===== entity 类型(我们的特色:实体标注)===== @Schema(description = "实体ID(type=entity时)") private String entityId; @Schema(description = "实体显示文本") private String entityText; @Schema(description = "实体类型", example = "PERSON/ORG/LOC/DATE/MONEY") private String entityType; @Schema(description = "是否已确认") private Boolean confirmed; // ===== link 类型 ===== @Schema(description = "链接URL") private String url; // ===== mention_doc 类型 ===== @Schema(description = "引用文档ID") private String refDocId; @Schema(description = "引用文档标题") private String refDocTitle; } /** * 文本样式 */ @Data @Schema(description = "文本样式") public static class TextStyle { private Boolean bold; private Boolean italic; private Boolean underline; private Boolean strikethrough; private String textColor; private String backgroundColor; private Boolean inlineCode; } /** * 获取块的纯文本内容(用于搜索和NER) */ public String getPlainText() { if (elements == null || elements.isEmpty()) { return ""; } StringBuilder sb = new StringBuilder(); for (TextElement el : elements) { if ("text_run".equals(el.getType()) && el.getContent() != null) { sb.append(el.getContent()); } else if ("entity".equals(el.getType()) && el.getEntityText() != null) { sb.append(el.getEntityText()); } } return sb.toString(); } /** * 获取块的HTML渲染(原文视图) */ public String toHtml() { if (elements == null || elements.isEmpty()) { return ""; } StringBuilder sb = new StringBuilder(); for (TextElement el : elements) { if ("text_run".equals(el.getType())) { sb.append(escapeHtml(el.getContent())); } else if ("entity".equals(el.getType())) { sb.append(escapeHtml(el.getEntityText())); } else if ("link".equals(el.getType())) { sb.append("") .append(escapeHtml(el.getContent())).append(""); } } return wrapWithTag(sb.toString()); } /** * 获取块的HTML渲染(标记视图,实体高亮) */ public String toMarkedHtml() { if (elements == null || elements.isEmpty()) { return ""; } StringBuilder sb = new StringBuilder(); for (TextElement el : elements) { if ("text_run".equals(el.getType())) { sb.append(escapeHtml(el.getContent())); } else if ("entity".equals(el.getType())) { String cssClass = getEntityCssClass(el.getEntityType()); String safeEntityId = escapeHtml(el.getEntityId()); String safeEntityType = escapeHtml(el.getEntityType()); sb.append("") .append(escapeHtml(el.getEntityText())) .append(""); } } return wrapWithTag(sb.toString()); } private String wrapWithTag(String content) { return switch (blockType) { case "heading1" -> "

" + content + "

"; case "heading2" -> "

" + content + "

"; case "heading3" -> "

" + content + "

"; case "bullet" -> "
  • " + content + "
  • "; case "ordered" -> "
  • " + content + "
  • "; case "quote" -> "
    " + content + "
    "; case "code" -> "
    " + content + "
    "; default -> "

    " + content + "

    "; }; } private String getEntityCssClass(String entityType) { return switch (entityType) { case "PERSON" -> "entity-highlight person"; case "ORG" -> "entity-highlight org"; case "LOC" -> "entity-highlight location"; case "DATE" -> "entity-highlight date"; case "NUMBER", "MONEY", "DATA" -> "entity-highlight data"; case "CONCEPT" -> "entity-highlight concept"; default -> "entity-highlight entity"; }; } private String escapeHtml(String text) { if (text == null) return ""; return text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\"", """); } }