PlayEdu 源码阅读笔记¶
个人学习记录。PlayEdu = 线上培训系统,Java + MySQL + React,前后端分离。后端 Spring Boot 3.3.4 / Java17 / MyBatis-Plus / Sa-Token(JWT)。 本文从「部署」切入(第一个小节),后续再补认证、权限、业务等。重要的多写、带图,次要的简记;优点标 ⭐、坑标 ⚠️。
一、部署架构(一键 Compose + 多阶段构建 + 内嵌迁移)¶
0. 总览:两容器,app 容器内 nginx + java 共存¶
一条命令起全套:docker-compose up -d,只有两个服务。
docker-compose
├── playedu (一个镜像,容器内同时跑 nginx + java)
│ nginx → 托 3 个前端静态站 + 把 /api 反代给同容器的 java
│ java → Spring Boot,监听 9898,启动时自动建表(迁移)
└── mysql (定制 mysql:8.1 + my.cnf + 命名卷 mysql-data 持久化)
| 端口(宿主→容器) | 用途 |
|---|---|
| 9900 → 9900 | 后台 admin(默认 admin@playedu.xyz / playedu) |
| 9800 → 9800 | PC 网页前台 |
| 9801 → 9801 | H5 移动端 |
| 9700 → 9898 | API(java 直连口) |
| 23307 → 3306 | MySQL |
⚠️ 单容器塞 nginx + java 是"体验优先"的反生产实践:CMD nginx; sleep 15; java -jar,无进程守护(nginx 挂了无人知)、sleep 15 傻等 MySQL。一键 demo 很香,生产应拆成独立容器。
1. Dockerfile 多阶段构建(核心花样)⭐¶
一个 Dockerfile 多个 FROM,每个 FROM = 一个独立、用完即弃的构建车间;只有最后一个 stage 成为最终镜像,前面的全丢弃。
| 阶段 | 基础镜像 | 干什么 |
|---|---|---|
① node-builder |
node:20 | 分别 build 三个 React 前端 admin/pc/h5 → dist |
② java-builder |
temurin:17 | mvnw package 打出 playedu-api.jar |
③ base(最终镜像) |
temurin:17(定制版含 nginx) | 把 jar + 三份 dist + nginx.conf 拢一起 |
解决的问题:构建期要的(node/pnpm/maven/源码/node_modules/.m2)又大又只在编译时用;运行期只要产物(jar + dist)。多阶段让你用"胖"镜像编译、只把产物倒进"瘦"镜像——构建工具/源码全留在被丢弃的车间里。
合并靠 COPY --from=<stage>(最终 stage 从前面 stage 文件系统隔空拣文件):
FROM temurin:17 AS base
COPY --from=java-builder /app/.../playedu-api.jar /app/api/app.jar
COPY --from=node-builder /app/admin/dist /app/admin
COPY --from=node-builder /app/pc/dist /app/pc
COPY --from=node-builder /app/h5/dist /app/h5
COPY docker/nginx/conf/nginx.conf /etc/nginx/... # 没 --from = 从构建上下文(宿主)拣
注:
temurin:17本身不含 nginx,但 CMD 跑 nginx → 说明这个定制 base 镜像预装了 nginx。最终 stage 的"运行底座"决定运行时有哪些环境(这里 JRE + nginx),产物只是 COPY 进去。
2. 依赖层缓存优化(先依赖、后源码)⭐⭐¶
最值得学的一招。 Docker 镜像分层,某层输入没变就复用缓存、且任一层失效会级联冲掉其后所有层。利用"依赖清单很少变、源码常变",把两者拆成不同层:
后端版(Dockerfile):
COPY mvnw .mvn pom.xml + 6个模块pom # ① 只拷依赖清单
RUN mvnw -B -DskipTests dependency:go-offline # ② 下载全部依赖 → 这层被缓存
COPY playedu-api /app # ③ 现在才拷源码
RUN mvnw -B -Dmaven.test.skip=true package # ④ 编译(依赖已在本地,飞快)
dependency:go-offline= 把构建所需依赖预下载到本地 .m2,不编译代码。- 关键:第②步只依赖 pom。改
.java源码 → ① 没变 → ② 缓存命中、几百 MB 依赖一个都不重下;只有改 pom 才触发重下。 - ⚠️ 注意 Docker 对
RUN的缓存只看命令字符串 + 上游层,不感知命令读了哪些文件——②会因 pom 变化而重跑,靠的是"COPY pom那层在前面、级联失效",不是 Docker"懂"它依赖 pom。缓存优化的本质是编排指令顺序。
前端版(同思路):先 COPY package.json + pnpm-lock → pnpm i,再 COPY 源码 build。一个 Maven 版、一个 pnpm 版,套路一样。
3. Maven Wrapper + 阿里云源 ⭐¶
mvnw / .mvn/ 是 Maven Wrapper:项目自带"装 Maven 的脚本",不依赖机器预装 mvn,版本锁定 + 零预装(CI/Docker 友好)。
| 文件 | 作用 |
|---|---|
mvnw/mvnw.cmd |
包装脚本 |
.mvn/wrapper/maven-wrapper.properties |
distributionUrl=下哪个 Maven(3.8.6,从白书自己的 download 站) |
.mvn/maven.config |
Maven 自动读取,内容当命令行参数追加 |
.mvn/settings.xml |
阿里云镜像配在这 |
阿里云源生效链路:.mvn/maven.config 里写了 -s .mvn/settings.xml -T 1C → Maven 每次自动加上 -s 指向项目内 settings.xml → 里面 <mirror><mirrorOf>central</mirrorOf> 把 central 改走 maven.aliyun.com。放项目里是为了让干净的构建环境(尤其 Docker,没 ~/.m2/settings.xml)也能用上镜像。
⚠️ 别混淆两层"阿里云":
| 加速对象 | 用的阿里云 | 配在哪 |
|---|---|---|
| Docker 基础镜像 | registry.cn-hangzhou.aliyuncs.com |
Dockerfile 的 FROM |
| Maven jar 依赖 | maven.aliyun.com/repository/public |
.mvn/settings.xml |
4. 前后端如何整合(nginx 反代)⭐¶
一个容器内 nginx 同时托三个前端静态站 + 把 /api 反代给同容器的 java。 三个 server 块结构相同(docker/nginx/conf/nginx.conf):
server {
listen 9900; # admin(pc=9800, h5=9801 同理)
root /app/admin; # 指向该前端的 dist
location /api/ {
proxy_pass http://127.0.0.1:9898/; # ← /api 反代给同容器 java
}
location ~* ^/(?![api].*) {
try_files $uri /index.html; # ← SPA 前端路由兜底
}
}
整合的两个关键点:
- 前端用相对路径
/api/调接口——构建时VITE_APP_URL=/api/(Dockerfile 里三个前端都这么 build)。前端不写死后端域名/端口,直接发/api/xxx。 - nginx 同源反代——前端站和
/api在同一个 nginx server 下,浏览器看来同源(无跨域),nginx 把/api/转给127.0.0.1:9898的 java。
请求流向:
浏览器 → http://宿主:9900/api/xxx
→ nginx(9900) 的 location /api/ → proxy_pass 127.0.0.1:9898 (java)
→ Spring Boot 处理 → 原路返回
静态资源 / 前端路由 → try_files → 命中 dist 文件 或 回退 index.html(SPA)
好处:前端零硬编码后端地址、天然同源无跨域、一个容器自洽。代价:nginx 和 java 绑死在一个容器(见总览的反生产实践)。
5. 环境变量如何影响 web 服务(双层默认值链路)⭐¶
全配置走环境变量 + 默认值,所以零配置可起。 一个变量从宿主到 Spring 配置,经过两层 ${VAR:-默认}:
宿主 .env / PLAYEDU_* 环境变量
│ ① compose.yml: DB_HOST=${PLAYEDU_DB_HOST:-mysql} ← 第一层默认(给容器)
▼
容器内环境变量 DB_HOST=mysql
│ ② application.yml: ${DB_HOST:127.0.0.1} ← 第二层默认(给本地dev)
▼
Spring 实际配置 spring.datasource.url = jdbc:mysql://mysql:3306/playedu
▼
HikariCP 建连 → MyBatis-Plus → 整个 web 服务跑起来
两层默认值各管一个场景(巧妙):
- compose 的
${PLAYEDU_DB_HOST:-mysql}→ 容器里DB_HOST总会被设成mysql(或宿主覆盖值); - yml 的
${DB_HOST:127.0.0.1}→ 只在脱离 compose 跑(本地 IDE 调试、没设 DB_HOST)时才用127.0.0.1。 - 即:compose 默认值服务于容器部署,yml 默认值服务于本地开发,互不打架。
关键变量 → 影响什么(application.yml / compose.yml):
| 环境变量 | application.yml 落点 | 影响 |
|---|---|---|
DB_HOST/PORT/NAME/USER/PASS |
spring.datasource.url/username/password |
连哪个库 |
DB_POOL_* |
spring.datasource.hikari.* |
连接池大小/超时 |
SERVER_PORT |
server.port(默认 9898) |
java 监听端口(要和 nginx 反代目标一致) |
SA_TOKEN_IS_CONCURRENT |
sa-token.is-concurrent(默认 false) |
是否允许同账号多端登录(false=后登顶替前登) |
SA_TOKEN_JWT_SECRET_KEY |
sa-token.jwt-secret-key(默认 playeduxyz) |
JWT 签名密钥(安全命门,见后续认证小节) |
DEMO_MODE |
playedu.demo.enabled |
演示模式:删除只删 DB、跳过 S3 真删 |
SPRING_PROFILES_ACTIVE |
profile(默认 dev) | 激活哪套配置 |
⚠️ 默认值即危险默认:不设环境变量直接起,就用上了空 DB 密码、SA_TOKEN_JWT_SECRET_KEY=playeduxyz、DB root 默认 playeduxyz——部署方不改环境变量 = 裸奔。
6. SQL 初始化(⚠️ 太难看:代码内嵌迁移)¶
没有任何 .sql 文件、没走 MySQL entrypoint、不是 Flyway。 是手搓的 Laravel 式迁移,由 MigrationCheck.java(一个 CommandLineRunner,@Order(10))在应用启动时执行:
public static final List<Map<String,String>> TABLE_SQL = {...30+张表的DDL硬编码成Java文本块...};
public void run(String... args) {
tables = jdbcTemplate.queryForList("show tables", ...); // 现有表
migrations = migrationService.all(); // 已跑过的迁移名
for (每条 TABLE_SQL) {
if (已在 migrations 表) continue; // 跑过→跳过
if (表已存在但无记录) store(name); // 补记录
else { jdbcTemplate.execute(sql); store(name); } // 建表+记录
}
}
- 用一张
migrations表按 name 去重做幂等——就是 Laravel migration 的 Java 翻版(团队 PHP/Laravel 背景)。 - 这串起了
sleep 15:java 一启动就跑迁移连 MySQL 建表,MySQL 没 ready 就建不出来 → 所以 CMD 要sleep 15等它。迁移机制正是 sleep 的根因。
⚠️ 坏味道("难看"在哪):
- DDL 塞进 880 行 Java 文本块——难 diff、无校验和、无回滚、无版本顺序强校验;改一条已应用迁移的 SQL 不会重跑也不报警(静默漂移,比 Flyway 弱)。
- catch 所有异常只 log——迁移失败不阻断启动,应用带病运行,后续查不存在的表才报错。
- COMMENT 大量复制粘贴错:
migrations表注释写成"课程章节表"、course_chapters写"管理员权限表"、users记录注释"分类表"……复制改 SQL 没改注释。 - 几乎没种子数据:这里只建表 + 一条往 migrations 补历史记录;默认管理员/权限/配置的种子在别处。
对照 mars-admin:那边是外置
.sql文件(还把明文凭证写进了开源 SQL)。PlayEdu 是代码内嵌迁移自动建表——部署方零手动执行 SQL,但 DDL 进 Java、改动难追踪。两种思路各有取舍。
7. 日志轮转(x-logging 锚点复用)⭐¶
x-logging: &default-logging # x- = compose 扩展字段(被忽略,放可复用片段); & = YAML 锚点
driver: "json-file"
options: { max-size: "10m", max-file: "10" } # 日志轮转:每容器最多~100MB
# 各服务: logging: *default-logging # * = 别名,引用锚点(一处定义多处复用)
- 作用:防 json-file 日志无限增长撑爆磁盘(生产易漏的一条)。
- 注意:json-file 抓的是容器主进程的 stdout/stderr(
docker logs读的就是它),不抓应用写进容器文件系统的日志文件。云原生提倡日志打 stdout 正是为了能被统一采集。
部署速查(⭐ 学到的 / ⚠️ 坑)¶
⭐ 值得学的"新花样"
- 多阶段构建:胖镜像编译、
COPY --from把产物倒进瘦镜像。 - 依赖层缓存:先 COPY 依赖清单单独装依赖、再 COPY 源码——本质是编排指令顺序利用 Docker 层缓存(前端 pnpm / 后端 maven 同理)。
- Maven Wrapper + 项目内阿里云 settings.xml:干净环境也能用镜像源。
- 前后端整合:前端
VITE_APP_URL=/api/相对路径 + nginx 同源反代到 java,零跨域零硬编码。 - 双层默认值环境变量:compose 默认服务容器、yml 默认服务本地 dev,零配置可起。
x-logging锚点 + 日志轮转防撑爆磁盘。
⚠️ 坑 / 反模式
- 单容器塞 nginx+java、
sleep 15等 MySQL、缺 healthcheck(应拆容器 +depends_on: condition: service_healthy)。 - SQL 初始化:DDL 硬编码进 Java、异常只 log 不阻断、COMMENT 复制粘贴错、无回滚/校验和。
- 危险默认值进仓:空 DB 密码、
SA_TOKEN_JWT_SECRET_KEY=playeduxyz、DB rootplayeduxyz。
二、认证鉴权(Sa-Token JWT + 自实现 RBAC)¶
0. 总览:Sa-Token 管登录态,权限自己算¶
PlayEdu 用了 Sa-Token + JWT,但没有像 mars-admin 那样完整走 StpInterface + @SaCheckPermission。它主要把 Sa-Token 当成登录态 / Token 工具:
StpUtil.login(...):签发 token;StpUtil.isLogin():判断是否登录;StpUtil.getLoginId():取当前登录 id;StpUtil.getExtra("prv"):从 JWT 里取账号类型。
后台权限是项目自己实现的:
@BackendPermission(slug)
↓
BackendPermissionAspect
↓
BackendBus.adminUserPermissions(adminId)
↓
admin_user_role / admin_role_permission / admin_permissions
所以整体分三层:
第一层:ApiInterceptor
全局 IP 限流,不是权限。
第二层:AdminInterceptor / FrontInterceptor
认证身份:有没有登录、token 是管理员还是学员、账号是否可用。
第三层:
后台:@BackendPermission + BackendPermissionAspect + BackendBus
门户:没有通用 RBAC,主要靠业务代码做课程/部门隔离。
1. 登录:多账号体系把 AuthService 逼出来了¶
后台管理员登录链路:
/backend/v1/auth/login
↓
校验邮箱、密码、是否禁用
↓
BackendAuthService.loginUsingId(adminId, loginUrl)
↓
AuthServiceImpl.loginUsingId(...)
↓
StpUtil.login(...)
↓
返回 token
登录时会往 JWT extra 里塞:
prv 是关键。PlayEdu 不是只判断“有没有登录”,还要判断“这个 token 是不是当前接口需要的账号类型”:
更别扭的是后台管理员 id 还做了偏移:
原因是项目没有使用 Sa-Token 原生多账号体系,而是两套账号共用全局 StpUtil。后台管理员和门户学员的 id 都可能从 1 开始,如果都直接 StpUtil.login(1),语义上会撞在同一个登录空间里。
于是项目用了双保险:
这能跑,但确实难看。AuthService 这一层也基本是被这个设计逼出来的:统一封装 StpUtil.login、check、真实 id 还原,避免业务代码到处手写 prv 和 id 偏移。
2. 后台请求鉴权流程¶
以 /backend/v1/course/index 为例:
请求 /backend/v1/course/index
│
▼
ApiInterceptor
├─ 判断限流白名单
├─ 按 IP 统计访问次数
└─ 超限返回 429
│
▼
AdminInterceptor
├─ 读取系统配置,写入 BCtx
├─ 判断后台免登录白名单
├─ authService.check()
│ ├─ StpUtil.isLogin()
│ └─ token.extra.prv == admin
├─ StpUtil.getLoginId() - 100000000 得到真实 adminId
├─ 查询 AdminUser
├─ 判断管理员是否禁用
├─ 查询当前管理员权限,写入 BCtx
└─ 放行
│
▼
BackendPermissionAspect
├─ 读取 Controller 方法上的 @BackendPermission.slug
├─ 再次查询当前管理员权限
├─ 判断权限 map 是否包含 slug
├─ 没有权限:返回 403
└─ 有权限:继续执行 Controller
│
▼
Controller / Service / Mapper
│
▼
afterCompletion
└─ BCtx.remove()
后台不是“登录即有权限”。登录只说明 token 合法,真正的功能权限靠接口上的 @BackendPermission。
3. 后台权限模型:固定权限字典 + 可配置角色¶
权限计算在 BackendBus.adminUserPermissions(adminId):
根据 adminId 查询角色 ids
↓
如果没有角色,返回空权限
↓
查询超级管理员角色 super-role
↓
如果当前管理员拥有 super-role,返回所有权限
↓
否则根据 roleIds 查询 permissionIds
↓
再把 permissionIds 转成 permission slug map
- 管理员(用户)管理;
- 角色管理;
- 管理员分配角色;
- 角色绑定权限;
- 前端按钮 / 菜单按权限隐藏;
- 后端接口按
@BackendPermission强校验。
只是菜单本身不是后端动态资源。菜单在前端写死,每个菜单挂一个权限 slug,然后根据当前管理员权限过滤展示。权限 slug 由代码定义,启动时同步到 admin_permissions 表。
这个设计问题不算大,属于够用型后台权限。代价是新增菜单/权限时要改代码并同步权限数据;如果是线上补权限,也可以直接插/改库处理。它不像 mars-admin 那种“菜单表 + 角色菜单 + 动态路由”的平台型权限系统,但对 PlayEdu 这种菜单相对固定的业务后台够用。
4. 门户用户:登录态 + 业务权限¶
门户 /api/v1/** 走 FrontInterceptor:
判断门户免登录白名单
↓
authService.check()
├─ StpUtil.isLogin()
└─ token.extra.prv == user
↓
查询 User
↓
判断是否锁定
↓
写入 FCtx
↓
放行 Controller
门户没有类似 @BackendPermission 的通用 RBAC,主要就是:
更细的权限散在业务代码里,例如部门课程隔离:
也就是说门户侧是“业务资源权限”,不是后台那种“功能按钮权限”。
5. 多账号体系更好的做法¶
方案一:用 Sa-Token 原生多账号体系。
给后台和门户各自定义 StpLogic:
public class StpAdminUtil {
public static final StpLogic stpLogic = new StpLogic("admin");
}
public class StpUserUtil {
public static final StpLogic stpLogic = new StpLogic("user");
}
这样身份天然是:
两边 id 重叠也没关系,不需要 adminId + 100000000,也不需要额外用 prv 再判断一遍账号类型。
如果再进一步接入 Sa-Token RBAC,可以实现:
后台 loginType=admin 时返回权限 slug,门户 loginType=user 时返回空列表或只保留业务判断。
方案二:不用 Sa-Token 原生,也至少显式建模主体。
如果还想自己封装,也不要用数字偏移。可以把登录主体建模成:
或者 loginId 直接用字符串:
这比 100000001 清楚,也不会把业务 id 和认证 id 混在一起。
6. 优缺点¶
- Sa-Token 用得浅:没有用好原生多账号、
StpInterface、权限缓存、@SaCheckPermission。 - 多账号设计难看:
prv + adminId 偏移能跑,但不如(loginType, loginId)清楚。 - 权限查询重复:
AdminInterceptor算一遍权限写入BCtx,BackendPermissionAspect又重新算一遍。 - 一次请求查库偏多:算权限时会查角色、超管角色、角色权限、权限详情等表,普通带权限接口轻松七八次库,甚至更多。
- 权限缺少缓存:角色权限低频变更,适合缓存到 SaSession / Redis / 本地缓存,变更时主动失效。
- RBAC 和数据权限混在一起:
admin_permissions同时放功能权限和数据字段权限,角色绑定时也是一起勾选;所谓数据权限主要是字段脱敏,不是独立的数据范围模型。
三、源码补充:模块、SQL、XML、事件¶
1. 模块拆分:拆了,但边界不清¶
后端拆成了 playedu-api / playedu-common / playedu-course / playedu-resource / playedu-system,但这个拆分不算特别清晰。
最别扭的是 common 和 system:直觉上系统级能力应该在 system,但很多系统基础能力其实混在 common:
- 认证服务:
AuthService、BackendAuthService、FrontendAuthService; - 上下文:
BCtx、FCtx; - 系统常量 / 权限常量 / 配置常量;
- 管理员、角色、权限、用户等领域对象;
- 配置读取、用户服务、管理员服务等 service。
system 反而主要放启动检查、内嵌迁移、权限初始化、配置初始化、日志切面。整体更像按生成器习惯和历史演进拆出来的模块,不是严格的领域分层。
3. Spring Event:主要做关联维护¶
Event 主要用来做“主业务完成后的关联维护”,不是复杂的领域事件系统。
大致分四类:
- 登录记录:管理员登录后更新登录 IP、时间、次数;学员登录/登出后写
user_login_records。 - 课程结构维护:新增/删除课时后重算课程
class_hour;删除章节后清理课时。 - 删除清理:删除课程后清理部门关联、分类关联、附件、学习记录;删除用户后清理部门关系、学习记录、学习时长、登录记录;删除部门/分类后解绑关系。
- 学习统计:课时完成后重算课程学习进度;学习心跳后写学习时长统计和明细。
它的好处是 Controller 没那么臃肿,删除/统计这类副作用被拆到 listener 里。
但它更多是代码形态解耦,不是可靠异步任务:大部分监听器只是同步 @EventListener,只有学员登录/登出用了 @Async。如果主操作和监听器不在同一个清晰事务边界内,某个清理步骤失败,就可能出现主表已删、部分关联没清完的半清理状态。
4. 学习进度追踪(冷门但通用:心跳 + 上报 + 锁 + 事件)¶
线上学习 / 视频观看进度 / 在线时长这类业务,这套机制可参考(HourController.java):
- 三个前端接口:
/play拿视频预签名 URL;/record上报已观看时长(duration >= totalDuration判完成);/ping心跳(前端定时打)累计在线时长。 - 防并发:
record/ping各按record:{userId}/ping:{userId}加锁。 - 心跳算时长:用
UserLastLearnTimeCache(内存)算两次心跳间隔,>10.5s 视为断开、补 10s。 - 解耦:完成 / 时长用
ApplicationEvent异步聚合(UserCourseHourFinishedEvent/UserLearnCourseUpdateEvent),不阻塞热路径。
⚠️ 为什么说不是最佳实践:
1. 进度静默丢失(硬伤):if (!tryLock) return JsonResponse.success();(HourController.java:133)——拿不到锁就直接返回成功、根本不记录。多标签页 / 快速重复请求 / 多实例(锁是单 JVM)下,进度被悄悄丢弃,前端却收到 success。对"进度追踪"这种核心功能是硬伤。
2. 心跳 / 锁 / last-learn-time 全在进程内内存——多实例下时长统计错乱、锁失效(同 三.6#5 单点实现)。
3. 正解:拿不到锁应重试 / 合并而非丢弃报成功;进度上报用幂等 upsert + 唯一索引或 Redis 原子操作;心跳状态放 Redis 才能多实例一致。
5. 其它优缺点速记¶
⭐ 值得学
- S3/MinIO 资源访问方向是对的:资源桶要求私有,访问走预签名 URL,不直接公开对象。
- 敏感配置有打码处理:
SecretKey、LDAP 密码这类is_private配置,后台读取时显示********,保存时跳过未变动的掩码值。 - SQL 排序白名单:
sort_field没有直接拼进 SQL,而是 XML<choose>分支映射到固定字段。
⚠️ 坑 / 反模式
- 密码算法旧:管理员和学员密码都是
MD5(password + salt),有 salt 但仍是快哈希;更合适的是 BCrypt / Argon2 / PBKDF2。 - 单点实现较多:内存缓存、内存锁、内存限流、定时 LDAP 同步都偏单实例思路;规模化部署时要替换成 Redis / DB 锁 / 分布式任务调度。
- 视频防盗链偏弱:私有桶 + 预签名方向对(⭐#1),但预签名 URL 有效期长达 3 小时、无分段 token / HLS 加密 / referer 校验——窗口内链接可被自由转发、直接下载原始视频(真正的转码加密 / 防快进在企业版)。改进:有效期缩到分钟级 + HLS 切片每段签名。
- Mapper XML大部分是生成器空壳:项目用了 MyBatis-Plus,普通 CRUD 本来可以不用 XML。源码里
mapper/*.xml很多,但大部分只有resultMap/Base_Column_List,没有真正的 SQL,阅读时会制造噪声