3、数据库设计与领域实体

第3章:数据库设计与领域实体

本章从数据库设计和领域实体的角度,拆解 publish 发布系统的数据模型。系统的核心职责是将 Git 仓库中的代码,按指定的版本(tag/branch),自动部署到目标服务器的指定路径------这决定了实体关系的设计走向。

3.1 核心实体关系梳理

publish 系统的实体模型可以分为三条线索:

主线(发布流程):Biz →\to→ App →\to→ Task →\to→ Flow

  • PublishBiz:一个可发布单元,对应一个 Git 仓库
  • PublishApp:Biz 的部署实例,指定目标机器、路径和环境
  • PublishTask:一次具体的发布操作,记录发布人、版本、目标机器
  • PublishFlow:Task 的步骤拆分,每步独立记录输出和耗时

辅助线(自动化支撑):

  • PublishHook:GitLab WebHook 注册,bizId 与 hookId 的映射
  • PublishRelation:分支 →\to→ App 的自动部署映射(哪个分支 push 后自动部署到哪个实例)
  • PublishDependency:Biz 的 Maven 依赖版本追踪
  • PublishPack:构建缓存(同一 biz + code 不重复打包)

账号线(认证与审计):

  • PublishAccount:本地登录账号(BCrypt 密码)
  • PublishUser:GitLab Private Token 绑定
  • PublishVisit:用户访问足迹记录

三条线以 Biz 为枢纽交织在一起:Biz 是核心,其余实体都通过 bizId 外键与之关联。

3.2 PublishBiz ------ 可发布单元

PublishBiz 是整个系统的中心概念,表示"一个可以独立构建和部署的代码单元"。在 publish 系统中,一个 Git 仓库 = 一个 Biz

源码 PublishBiz.java

java 复制代码
@Getter
@Setter
@TableName("publish_biz")
public class PublishBiz extends BaseEntity {
    private String name;          // 业务名称
    private String git;           // Git 仓库地址
    private String owner;         // 负责人
    private String module;        // 子模块名(Maven 多模块项目时指定具体模块)
    private String type;          // 类型: java / jar / python / angular
    private String warmUp;        // 预热 URL(多个逗号分隔)
    private String preScript;     // 打包前置脚本
    private String postScript;    // 启动后脚本
    private long projectId;       // GitLab 项目 ID
    private boolean mask;         // 是否用 iptables 屏蔽端口
    private boolean testPass;     // 是否测试通过
    private boolean timeRestrict; // 是否有上线时间限制
    private boolean pingable;     // 是否接入 ping 监控

    @TableField(exist = false)
    private long deployTimes;     // 瞬态字段:发布次数统计,不持久化
}

几个设计要点值得关注:

type 字段的多语言支持。 系统不只支持 Java 应用------jar 类型表示独立 JAR 包部署,python 表示 Python 项目,angular 表示前端项目。不同 type 在后续的构建和部署步骤中执行不同的脚本逻辑。

preScript / postScript 的扩展点设计。 这两个字段允许运维人员在标准部署流程之外注入自定义命令。preScriptmvn package 之前执行(例如拉取私有依赖),postScript 在部署完成后执行(例如通知监控系统、触发健康检查)。这避免了为每个特殊需求修改核心逻辑。

getTagUrl() 方法的推导逻辑。 Biz 从 Git URL 自动推导 GitLab 的 tags 页面地址:

java 复制代码
public String getTagUrl() {
    if (git == null) {
        return null;
    }
    return git.replace(".git", "/tags");
}

例如 git@gitlab.com:team/project.git 会被转换为 git@gitlab.com:team/project/tags,供前端页面直接链接到 GitLab 的版本标签页。

3.3 PublishApp ------ 部署实例

PublishApp 描述一个 Biz 的一个具体部署实例------"哪个业务、部署到哪台机器、哪个端口、哪个路径、什么环境"。同一 Biz 的同一 profile 可以部署到多台机器(水平扩展场景)。

源码 PublishApp.java

java 复制代码
@Getter
@Setter
@TableName("publish_app")
public class PublishApp extends BaseEntity {
    private long bizId;           // 关联 publish_biz.id
    private String name;          // 应用名称
    private String profile;       // 环境分组: yinke / pre / dev / ceshi113 ...
    private String ip;            // 服务器 IP
    private int port;             // HTTP 端口
    private String path;          // 部署路径,如 /data/tomcat/webapps/ROOT
    @TableField(updateStrategy = FieldStrategy.IGNORED)
    private String locker;        // 锁定者(防止并发发布)
    private String code;          // 当前部署版本(tag/branch)
    private String env;           // bin/setenv.sh 内容
    private String conf;          // conf/server.xml 内容
    private String description;   // 备注

    @TableField(exist = false)
    private PublishBiz biz;               // 瞬态:关联的 Biz 对象

    @TableField(exist = false)
    private Object runningStatus;         // 瞬态:运行时状态(不持久化)
}

getFullPath() ------ 部署坐标的唯一标识。 ip:path 组合为每台机器上的部署提供了唯一坐标:

java 复制代码
public String getFullPath() {
    return ip + ':' + path;
}

由于一台服务器上可能部署多个应用,仅靠 IP 无法唯一定位,加上 path 之后才构成完整的部署坐标。这也对应数据库中的唯一索引 uk_ip_path

locker 字段的并发控制。 locker 采用乐观锁策略------发布前检查是否有其他人在操作同一实例,发布完成后释放。@TableField(updateStrategy = FieldStrategy.IGNORED) 表示更新时即使 locker 为 null 也会写入数据库,保证锁的释放能正确持久化。

profile 环境分组。 profile 是连接 Biz 和机器的重要维度。通过 findByBizIdAndProfile(bizId, profile) 方法,DeployService 可以精确选定"某个业务在某个环境下的所有部署实例",然后逐台执行发布。环境分组使得同一 Biz 的测试环境(pre)和生产环境(yinke)的发布流程可以隔离管理。

3.4 PublishTask ------ 发布任务

PublishTask 是一次具体的发布操作记录。它保存了"谁、对哪个业务、发布了哪个版本、到哪些机器"的完整信息。

源码 PublishTask.java

java 复制代码
@Getter
@Setter
@TableName("publish_task")
public class PublishTask extends BaseEntity {
    private String type;          // 任务类型: pkg / deploy / onekey / retry / rollback
    private long bizId;           // 关联 publish_biz.id
    private long cost;            // 耗时(毫秒),DB 持久化字段
    private String name;          // 业务名称快照
    private String profile;       // 环境分组
    private String editor;        // 发布人
    private String code;          // 发布版本(tag/branch)
    private String processes;     // 目标机器列表(ip:path 逗号分隔)
    private String description;   // 发布说明
    private String err;           // 错误信息(V3 迁移新增)
    private String status;        // 状态:create / running / succ / fail / killed / cancelled / timeout
    @TableField("`interval`")
    private int interval;         // 多台机器间发布间隔(秒)

    // 瞬态字段,不持久化:
    @TableField(exist = false) private List<Long> apps;
    @TableField(exist = false) private PublishBiz biz;
    @TableField(exist = false) private List<PublishFlow> flows;
    @TableField(exist = false) private List<PublishApp> appList;
    @TableField(exist = false) private boolean pack;
}

瞬态字段设计。 appsbizflowsappList 这四个字段全部标注了 @TableField(exist = false),表示它们只存在于 Java 对象的内存中,不映射到数据库列。它们的作用是运行时数据传递------当 Controller 层传入 Task 对象时,这些字段携带了关联的 App ID 列表、Biz 详情和 Flow 列表,供 Service 层使用。这种设计避免了在每个方法调用时反复查询关联数据。

getCost() 的两阶段计时策略。 这是 Task 设计中最精妙的地方:

java 复制代码
public long getCost() {
    if (createTime != null
            && ("running".equals(status) || "create".equals(status))) {
        return System.currentTimeMillis() - createTime.getTime();
    }
    return cost;
}

任务运行时,createTime 对象还在内存中(瞬态),getCost() 实时计算当前时间与创建时间的差值。任务结束后,stop() 方法将计算结果写入 cost 字段并持久化到数据库,此后 getCost() 直接返回 DB 中存储的最终耗时。这样一来,前端轮询运行中的任务时能获得实时耗时,历史任务查询时又能直接从数据库读取,无需额外计算。

getHosts() 从 processes 文本中提取 IP。 processes 字段存储的是类似 192.168.1.1:/data/app,192.168.1.2:/data/app 的文本,getHosts() 用正则 \b(?:\d{1,3}\.){3}\d{1,3}\b 提取出纯 IP 列表,供 SSH 连接使用。

7 种任务状态。 Task 的生命周期覆盖了完整的发布流程:create(创建)→\to→ running(执行中)→\to→ succ(成功)/ fail(失败)/ killed(被终止)/ cancelled(被取消)/ timeout(超时)。interval 字段控制多台机器间发布的时间间隔,防止流量瞬间涌入刚启动的单台实例。

err 字段是后期新增。 最初的任务表没有 err 字段,V3 迁移通过 ALTER TABLE publish_task ADD COLUMN err TEXT 补充。这使得 Task 本身可以直接记录顶层错误信息,而不必到 Flow 中逐个排查。

3.5 PublishFlow ------ 步骤流水(核心创新)

PublishFlow 是整个系统在设计层面最值得关注的实体。它将一个发布任务拆分为多个独立的步骤,每个步骤都有独立的名称、状态、标准输出、错误输出和耗时。

源码 PublishFlow.java

java 复制代码
@Getter
@Setter
@TableName("publish_flow")
public class PublishFlow extends BaseEntity {
    private static final int MAX_TEXT_LEN = 2000;

    private long taskId;          // 关联 publish_task.id
    private String name;          // 步骤名称,如 "checkout" / "package" / "deploy"
    private String status;        // 状态
    private String step;          // 步骤序号,如 "1/5"
    private long cost;            // 耗时(毫秒)
    @TableField("`out`")
    private String out;           // 标准输出
    @TableField("`err`")
    private String err;           // 错误输出
}

out/err 字段的自动净化。 这是 Flow 设计中最具工程价值的细节。执行 Maven 构建或 SSH 命令时,输出中可能包含 ANSI 转义码(Maven 的彩色日志)、换行符等特殊字符,直接写入 MySQL 会导致 PreparedStatement 参数绑定异常------尤其是 MySQL Connector/J 9.x 对 \n 字符更加敏感。Flow 的 setter 方法内部自动执行净化:

java 复制代码
public void setOut(String out) {
    this.out = sanitizeField(out);
}

private static String sanitizeField(String s) {
    if (s == null) return null;
    // 1. 去掉 ANSI 转义码
    String cleaned = s.replaceAll("\\e\\[[\\d;]*[a-zA-Z]", "");
    // 2. 换行/回车 → 空格(MySQL Connector/J 9.x 兼容)
    cleaned = cleaned.replace('\n', ' ').replace('\r', ' ');
    // 3. 去掉其余 ASCII 控制字符
    cleaned = cleaned.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]", "");
    // 4. 截断到 2000 字符(取末尾,保留最新的输出)
    if (cleaned.length() > MAX_TEXT_LEN) {
        cleaned = cleaned.substring(cleaned.length() - MAX_TEXT_LEN);
    }
    return cleaned;
}

净化分四步:

  1. ANSI 转义码去除 :通过正则 \e\[[\d;]*[a-zA-Z] 匹配形如 \e[1;32m 的颜色控制序列并移除。
  2. 换行转空格\n\r 替换为空格。这是 MySQL Connector/J 9.x 兼容的关键------高版本驱动对包含 \n 的参数绑定会抛出异常。
  3. 其余控制字符清除 :去除 \x00-\x08\x0B\x0C\x0E-\x1F 范围内的不可见字符。
  4. 末尾截断 :超过 2000 字符时,保留末尾(最新)的 2000 字符。取末尾而非开头,是因为流水执行过程中,最末尾的输出往往包含最关键的结论信息(如构建失败的原因)。

注意 Lombok 的 @Setter(AccessLevel.NONE) outerr 字段上都加了 @Setter(AccessLevel.NONE),这禁止了 Lombok 生成默认的 public setter。原因是这两个字段的设值必须经过 sanitizeField 净化逻辑------如果允许直接赋值,就会绕过净化步骤,导致脏数据写入数据库。开发者在 Flow 对象的其他地方赋值时,只能调用 setOut()setErr() 方法,确保净化逻辑始终生效。

@TableField("`out`") 的反引号映射。 out 是 MySQL 保留字,因此 @TableField 中使用反引号包裹列名,生成的 SQL 为 out,避免语法冲突。

getCost() 和 stop() 的两阶段计时。 与 PublishTask 完全相同的设计模式------运行中实时计算,结束后从 DB 取持久化值。这种一致性使得前端无论是轮询进行中的任务还是查看历史记录,都能获得正确的耗时数据。

3.6 辅助实体

PublishHook ------ WebHook 注册

java 复制代码
@TableName("publish_hook")
public class PublishHook extends BaseEntity {
    private long bizId;        // 关联 publish_biz.id
    private String hookId;     // GitLab WebHook ID
    private String type;       // Hook 类型
}

一条 Biz 可以注册一个或多个 GitLab WebHook。当 GitLab 收到 push 事件时,回调 publish 系统的 WebHook 接口,系统根据 hookId 找到对应的 bizId,再结合分支匹配 PublishRelation,决定是否触发自动部署。

PublishRelation ------ 自动部署映射

java 复制代码
@TableName("publish_relation")
public class PublishRelation extends BaseEntity {
    private long bizId;        // 关联 publish_biz.id
    private long appId;        // 关联 publish_app.id
    private String branch;     // Git 分支名

    @TableField(exist = false) private String appName;
    @TableField(exist = false) private String ip;
    @TableField(exist = false) private String path;
}

PublishRelation 建立"分支 →\to→ 部署实例"的映射。例如配置 dev 分支关联到测试环境的 App,push 到 dev 分支后自动触发该 App 的部署。appNameippath 是瞬态字段,用于前端列表页面直接展示 App 的关键信息,避免多次查询。

PublishDependency ------ 依赖版本追踪

java 复制代码
@TableName("publish_dependency")
public class PublishDependency extends BaseEntity {
    private long bizId;       // 关联 publish_biz.id
    private String name;      // 依赖名称,如 "justgotrip-core"
    private String version;   // 版本号,如 "1.2.3"
    private Date updateTime;  // 更新时间(ON UPDATE CURRENT_TIMESTAMP)
}

用于追踪每个业务引用的核心依赖版本。当 justgotrip-core 发新版时,运维人员可以快速排查哪些业务尚未升级。

PublishPack ------ 构建缓存

java 复制代码
@TableName("publish_pack")
public class PublishPack extends BaseEntity {
    private long bizId;       // 关联 publish_biz.id
    private String code;      // 版本标识(tag/branch)
    private String path;      // 包文件路径
    private String profile;   // 环境分组

    @TableField(exist = false)
    private String owner;     // 瞬态:文件大小,不持久化
}

构建缓存的核心价值在于:同一 (bizId, code) 组合只构建一次。当多台机器需要部署同一个版本时,第一台触发 mvn package,后续机器直接复用服务器上已有的 WAR/JAR 包。数据库唯一索引 uk_biz_code 保证了同一版本不会重复插入缓存记录。getSizeMB() 方法从文件系统实时获取包大小,不在数据库中冗余存储。

PublishAccount ------ BCrypt 登录

java 复制代码
@TableName("publish_account")
public class PublishAccount extends BaseEntity {
    private String username;     // 登录用户名
    private String password;     // BCrypt 加密密码
    private String displayName;  // 显示名称
    private String role;         // 角色: admin / user
    private boolean enabled;     // 是否启用
}

系统采用本地账号认证而非 SSO,密码使用 BCrypt 加密存储。V2 迁移自动插入默认管理员账号 admin/admin123

PublishUser ------ GitLab Token 绑定

java 复制代码
@TableName("publish_user")
public class PublishUser extends BaseEntity {
    private String name;         // 用户名
    private String email;        // 邮箱
    private String displayName;  // 显示名
    private String token;        // GitLab Private Token
    private String role;         // 角色
}

token 字段存储 GitLab 的 Private Token,用于调用 GitLab API(如查询 tags、创建 WebHook)。

PublishVisit ------ 访问足迹

java 复制代码
@TableName("publish_visit")
public class PublishVisit extends BaseEntity {
    private String editor;       // 用户名
    private String footprints;   // 访问足迹(bizId 逗号分隔)
}

footprints 字段用逗号分隔的 bizId 列表记录用户最近访问过的业务,用于前端"最近使用"快捷入口。每个用户只保留一条记录(uk_editor 唯一索引),每次访问时更新覆盖。

3.7 枚举设计

TaskStatus ------ 任务状态

java 复制代码
public enum TaskStatus {
    create("create"), running("running"), succ("succ"),
    fail("fail"), killed("killed"), cancelled("cancelled"),
    timeout("timeout");

    private final String name;

    public boolean isRunning() {
        return this == running || this == create;
    }

    public boolean isError() {
        return this != succ;
    }

    public static TaskStatus valueOfName(String name) {
        for (TaskStatus s : values()) {
            if (s.name.equals(name)) return s;
        }
        return create;
    }
}

状态枚举提供了两个关键的辅助判断方法:

  • isRunning():将 createrunning 都视为"执行中"。这是因为任务从创建到真正开始执行之间可能存在短暂延迟,将两者统一处理避免了状态判断的边界问题。
  • isError():非 succ 即异常。这个反向判断简单实用------只要不是成功,就需要关注。

valueOfName() 是 Java valueOf() 的安全替代,传入未知字符串时返回默认值 create 而非抛出 IllegalArgumentException。这在从数据库读取状态字符串时尤为重要------数据库可能因为代码升级而包含尚未定义的枚举值。

TaskType ------ 任务类型

java 复制代码
public enum TaskType {
    unknown("unknown"), pkg("pkg"), deploy("deploy"),
    onekey("onekey"), retry("retry"), rollback("rollback");
}

五种类型覆盖了完整的发布操作集合:

  • pkg:仅构建打包,不部署
  • deploy:仅部署已有的包
  • onekey:一键打包+部署(最常用)
  • retry:对失败的任务重试
  • rollback:回滚到上一版本

3.8 完整表结构

系统使用 Flyway 管理数据库版本,初始版本 V1 包含 12 张核心表,V2 新增账号表,V3 为任务表补充 error 字段。

主线表

publish_biz ------ 唯一索引 uk_name 保证业务名不重复,uk_module_git 防止同一 Git 仓库的同一模块被重复注册。

sql 复制代码
CREATE TABLE `publish_biz` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `name`          VARCHAR(64)     NOT NULL,
  `git`           VARCHAR(512)    NOT NULL DEFAULT '',
  `owner`         VARCHAR(100)    DEFAULT NULL,
  `module`        VARCHAR(256)    DEFAULT NULL,
  `type`          VARCHAR(32)     NOT NULL DEFAULT 'java',
  `pre_script`    VARCHAR(2048)   DEFAULT NULL,
  `post_script`   VARCHAR(2048)   DEFAULT NULL,
  `warm_up`       VARCHAR(1024)   DEFAULT NULL,
  `mask`          TINYINT(1)      NOT NULL DEFAULT 0,
  `project_id`    BIGINT          NOT NULL DEFAULT 0,
  `test_pass`     TINYINT(1)      NOT NULL DEFAULT 0,
  `time_restrict` TINYINT(1)      NOT NULL DEFAULT 0,
  `pingable`      TINYINT(1)      NOT NULL DEFAULT 0,
  `create_time`   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_name` (`name`),
  UNIQUE KEY `uk_module_git` (`module`, `git`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

publish_app ------ 唯一索引 uk_ip_path 确保同一台机器的同一路径只有一个应用实例;普通索引 idx_biz_id 支撑按业务查询所有部署实例的高频操作。

sql 复制代码
CREATE TABLE `publish_app` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `biz_id`        BIGINT UNSIGNED NOT NULL,
  `name`          VARCHAR(128)    NOT NULL,
  `profile`       VARCHAR(50)     NOT NULL,
  `ip`            VARCHAR(50)     NOT NULL,
  `port`          INT             NOT NULL DEFAULT 0,
  `path`          VARCHAR(512)    NOT NULL,
  `locker`        VARCHAR(100)    DEFAULT NULL,
  `code`          VARCHAR(255)    DEFAULT NULL,
  `env`           TEXT            DEFAULT NULL,
  `conf`          TEXT            DEFAULT NULL,
  `description`   TEXT            DEFAULT NULL,
  `publish_time`  TIMESTAMP       NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_ip_path` (`ip`, `path`),
  KEY `idx_biz_id` (`biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

publish_task ------ interval 使用反引号包裹(MySQL 保留字)。四个普通索引覆盖了按业务查询、按发布人查询、按状态过滤、按时间排序的常见查询模式。err 字段通过 V3 迁移补充。

sql 复制代码
CREATE TABLE `publish_task` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `biz_id`        BIGINT UNSIGNED DEFAULT NULL,
  `type`          VARCHAR(20)     NOT NULL DEFAULT 'onekey',
  `status`        VARCHAR(20)     NOT NULL DEFAULT 'create',
  `name`          VARCHAR(256)    NOT NULL,
  `profile`       VARCHAR(100)    DEFAULT NULL,
  `code`          VARCHAR(255)    DEFAULT NULL,
  `editor`        VARCHAR(100)    NOT NULL,
  `description`   TEXT            DEFAULT NULL,
  `processes`     TEXT            DEFAULT NULL,
  `interval`      INT             NOT NULL DEFAULT 0,
  `cost`          BIGINT          DEFAULT NULL,
  `create_time`   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_biz_id` (`biz_id`),
  KEY `idx_editor` (`editor`),
  KEY `idx_status` (`status`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

辅助与账号表索引设计总结

表名 核心唯一索引 核心普通索引
publish_biz uk_nameuk_module_git ---
publish_app uk_ip_path idx_biz_id
publish_task --- idx_biz_ididx_editoridx_statusidx_create_time
publish_flow --- idx_task_id
publish_pack uk_biz_code ---
publish_hook --- idx_biz_id
publish_dependency --- idx_biz_id
publish_relation uk_app_branch idx_biz_id
publish_user uk_name ---
publish_visit uk_editor ---
publish_account uk_username ---

整体设计遵循一个原则:唯一索引保障数据完整性,普通索引覆盖高频查询列 。Biz 和 App 的查询最为频繁(每次发布都要查),因此 idx_biz_id 散布在几乎所有关联表上。Task 表额外加了按编辑人和按状态的索引,因为前端列表页面支持按发布人和任务状态做筛选。

本章小结

publish 系统的数据模型以 Biz(可发布单元) 为核心,通过 App(部署实例) 描述部署目标,Task(发布任务) 记录每次操作,Flow(步骤流水) 提供细粒度的执行追踪。实体设计中值得关注的关键工程决策包括:

  • Flow 的 sanitizeField 自动净化输出文本,防止 ANSI 转义码和换行符污染数据库
  • Task 和 Flow 的 getCost() 两阶段计时------运行中实时计算,结束后从 DB 取
  • @TableField(exist = false) 瞬态字段在实体间传递关联数据,避免反复查询
  • valueOfName() 安全解析枚举,替代 Java 原生 valueOf() 避免未知值抛异常
相关推荐
码哥字节1 小时前
Anthropic 官方的 7 种 Claude Code 自定义方式,你可能只用对了 1 种
ai编程·claude·mcp
舞影天上1 小时前
RuoYi-Vue-Plus Docker 部署踩坑:MySQL 中文双重编码的根因与修复
数据库
程序员老刘1 小时前
Android 17的10个AI功能,国内用户真正能用的有几个?
android·flutter·ai编程
赫媒派1 小时前
Agent 记忆 5 个方案对比:接入不踩坑
ai编程
C++、Java和Python的菜鸟2 小时前
第4章 后端Web基础(基础知识)
java·开发语言
芷栀夏2 小时前
Java教育平台实战复盘:课程、考试与学习行为分析系统设计
java·开发语言·学习
Wzx1980122 小时前
Redis&ES——Retriever的抽象实现
数据库·人工智能·redis·elasticsearch
G.O.G.O.G2 小时前
《LeetCode SQL 从入门到进阶(MySQL)》06(下)
数据库·sql·oracle
维基框架2 小时前
维基框架(Wiki-Framework) 1.2.0:Mybatis 升级为主从读写分离
java·后端·架构