坑位索引
适用端:后端 + Web + 移动端 | 类型:排错
本页是跨端通用的坑的汇总索引,29 条(编号 A1–F6),按类别分组。每条只写「现象 → 根因 → 怎么办」,细节链到对应正文页。
移动端专属的另有 24 条(编号 M1–M24),单独成页:移动端坑位。本页的 G 节只把其中会咬人的几条指过去。
三条使用约定:
- 每一条都有实测证据——日志行、接口返回、
git ls-files输出、数据库查询结果或源码锚点。没有证据的不进这个清单。 - 标 🔴 的是会咬人的:数据丢失、安全暴露、或排查方向会被带偏到别处。上线前把下面索引表里标 🔴 的条目全部过一遍,再加上 移动端坑位 里标 🔴 的那几条(本页 G 节已经列出来了,不用两头翻)。这里刻意不写条数:它是索引表的一个筛选结果,写死一个数字等于又造一处会漂移的拷贝——
F6加进来时这句话就曾经停在旧值上。 - 正文页里也有各自的「坑与注意」段,那些是贴着上下文的提醒;本页是按症状找的索引。同一件事可能两边都出现,不重复展开。
索引
条目前缀就是类别(A 安全 / B 构建部署 / C 数据库 / D 菜单路由 / E 代码生成器 / F 测试协作 / G 移动端指路),与下面的分节一一对应。
最后一列是「机制在哪页」——本页只写现象与怎么办,为什么会长成这样在对应正文页里讲。
| # | 一句话 | 严重度 | 机制在哪页 |
|---|---|---|---|
| A1 🔴 | Bearer test1 就是超级管理员。已修,但开关一开就复活 | 致命 | 环境准备与启动 · system 域 |
| A2 🔴 | /druid/* 无鉴权,暴露 JDBC URL 与慢 SQL 原文。已修,但改配置要重启才生效 | 高 | 构建与部署 |
| A3 🔴 | 208 次 GetConnectionTimeoutException | 高 | 构建与部署 |
| A4 | prod 配置里留着「本地环境」注释 | 中 | 环境准备与启动 · system 域 |
| B1 | 忘传 --spring.profiles.active 会连本地库 | 高 | 构建与部署 |
| B2 🔴 | 生产包登录页没有验证码 | 高 | 构建与部署 · 前端工程结构 |
| B3 | docker / jenkins / shell 全是上游遗留 | 中 | 构建与部署 |
| B4 | Windows 文件锁 | 低 | 构建与部署 |
| B5 | 106 个 .vm 嵌在 infra jar 里 | 中 | 新增一个菜单功能端到端 · infra 域 |
| C1 🔴 | DO 基类和表的真实结构可以不一致 | 高 | 数据库约定与表总览 |
| C2 | 94 张带 tenant_id 的表只有 26 张建了索引 | 低 | 数据库约定与表总览 |
| C3 🔴 | 失败信息指向 schema-locations | 中 | 测试规范 |
| C4 🔴 | 传 null 会被 MyBatis-Plus 静默忽略 | 高 | 接口约定与 CRUD 骨架 · 测试规范 |
| C5 | 方言差异 | 低 | 数据库约定与表总览 |
| D1 🔴 | findIndex 返回 -1,没人检查 | 高 | 路由与权限机制 |
| D2 | 只有走 UI 才 @CacheEvict | 中 | 新增一个菜单功能端到端 |
| D3 🔴 | super_admin 走短路 | 高 | 诊断与排错 · 新增一个菜单功能端到端 |
| D4 | 两个字段语义容易记反 | 中 | 路由与权限机制 |
| D5 | 其它前缀要联网拉 Iconify | 低 | 通用组件与 hooks |
| E1 🔴 | ${table.parentMenuId} 原样出现在产物里 | 高 | 新增一个菜单功能端到端 |
| E2 | Field 'author' doesn't have a default value | 中 | 新增一个菜单功能端到端 |
| E3 | 只有两个 GET 接口 | 中 | 接口约定与 CRUD 骨架 |
| E4 | 直接拷进去会和邻居长得不一样 | 中 | 新增一个菜单功能端到端 |
| F1 | sort 前端放行、后端 400 | 中 | 接口约定与 CRUD 骨架 |
| F2 | auto-*.d.ts 只由 dev server 生成 | 中 | 前端工程结构 |
| F3 | commentLineRefBan.spec.ts 5000 ms | 低 | 测试规范 |
| F4 | 配置文件和目录都不存在 | 低 | 测试规范 |
| F5 🔴 | 3384 个文件的 blame 停在「项目初始化」 | 中 | 仓库结构与改名约定 |
| F6 🔴 | 注释里写 flexShrink:0,整套前端单测红一天 | 中 | 测试规范 |
锚点是编号,不是标题
上表与全站指向本页的链接一律写 #a1、#d3 这种编号锚点(标题后用 {#a1} 显式声明),而不是 VitePress 从标题生成的长 slug。
理由是改标题的代价:slug 由标题算出来,标题一改,全站指向它的链接就全得跟着改。A1 的标题已经改过一次,当时有 3 处外链;条目只会越来越多,这个成本只会涨。编号锚点把「标题怎么写」和「怎么被引用」解耦了。
⚠️ 别指望忘了改会立刻被发现:pnpm docs:build 拦不住死 hash(ignoreDeadLinks 不管 fragment),只有 pnpm check:anchors 会报,而它要求先跑一次新鲜的 build(见 tools/check-anchors.cjs 的 stale 检查)。
所以约定是:改标题随意,别改 {#xx};新增条目顺着字母加 A5、F7,编号一旦发出就不复用、不重排(F6 已用)。移动端专属的坑不进这个空间,进 移动端坑位 的 M 空间。
加完条目跑一次 pnpm check:pages,它会替你把全站的条数声明找出来
「共 N 条」「编号 A1–F6」这类声明散在导读、任务索引与两页坑位索引的互指里(本轮实测 23 处,其中 19 处是全量声明),加一条就要改一圈,而且漏改不报错——check-anchors 只会验锚点存不存在,#f5 一直都在。所以 check-pages.cjs 直接拿两页真实的条目锚点做对账:范围端点不存在、全量范围末位过期、「N 条」与真实条数不符,任一种都 exit 1。 写法上只有一条要求:声明全量时必须写成「编号 X1–Yn」(编号 两字在前)。不带「编号」的范围被当成子集,只验端点存在——「能直接抄的列表页骨架(M1–M4)」这种页内分工说明就属于子集,不必也不该跟着全量走。
:::
A. 安全
A1. 免登录 mock token 曾长期在生产 profile 里开着
✅ 已修(476b4b2637,2026-10-18)。留在索引里是因为失效方向和排查方式仍然适用,而且它是「配置复制粘贴传播」的典型样本。
🔴 当初的现象:对着生产环境发一个不带任何真实令牌的请求,直接拿到超级管理员身份。
curl http://<prod>/admin-api/system/user/profile/get \
-H "Authorization: Bearer test1" -H "tenant-id: 1"
# {"code":0,"msg":"","data":{"id":1,"username":"admin","nickname":"超级管理员",...}}根因:application-local.yaml 和 application-prod.yaml 都曾写着 enlev.security.mock-enable: true(只有 application-dev.yaml 没设,因此走 SecurityProperties.java:34 的默认值 false)。mock-secret 全项目从未覆盖,用的是 SecurityProperties.java:40 的默认值 "test"。TokenAuthenticationFilter#mockLoginUser 只要 token 以 mockSecret 开头,就把剩下的部分当成用户编号:test + 1 → userId=1 → admin → 超管短路 → 所有 @PreAuthorize 放行。tenantId 还是从请求头取的,租户也能随便挑。
现状:三个 profile 全部 mock-enable: false(local :246、dev :247、prod :248,实测 2026-10-25)。代码路径一行没改 —— mockLoginUser 还在,开关一打开就立刻复活。
怎么办:这条 curl 现在必须返回 code: 401;返回 code: 0 就是有人又把开关打开了。🔴 新增 profile 或复制 yaml 时这一行要逐个核,别指望 SecurityProperties 的默认值兜底 —— 默认值确实是安全的 false,但风险从来都来自配置文件覆盖,而配置文件是照着邻居抄的(见 A4)。真要临时用模拟登录,改 mock-enable 的同时必须配一个不可猜的 mock-secret,且只在 local 改、不要提交。
TokenAuthenticationFilter.java:98 的方法注释自己写着「注意,在线上环境下,一定要关闭该功能!!!」。详见构建与部署与 system 域开发。
A2. Druid 控制台三个环境全开且无口令
✅ 已修(3315ae157d,2026-10-25)。留在索引里是因为它失效的方向很隐蔽:接口全部返回 200、业务一切正常,只有控制台在悄悄往外吐数据;而且改的是 yaml,必须重启后端才生效,正在跑的进程仍是旧配置。
🔴 当初的现象:浏览器直接打开 http://<host>:48080/druid/index.html,不需要登录。
根因:三个 profile 里 spring.datasource.druid.stat-view-servlet 都是
enabled: true
allow: # 设置白名单,不填则允许所有访问
url-pattern: /druid/*
login-username: # 控制台管理用户名和密码
login-password:allow 空 = 不限 IP,login-username / login-password 空 = 不校验。
暴露了什么(2026-10-25 对着本地跑着的 :48080 实测,全部无鉴权 200):
| 端点 | 返回 |
|---|---|
/druid/datasource.json | 完整 JDBC URL(含库名与全部连接参数)、UserName: root、连接池全部实时指标 |
/druid/sql.json | 194 KB 已执行 SQL 原文与参数(log-slow-sql: true、slow-sql-millis: 100) |
/druid/weburi.json | 全部接口路径 |
🔴 为什么不能靠 Spring Security 拦:SecurityConfiguration.java:30 那句 /druid/** 的 permitAll 必须留着——Druid 的 StatViewServlet 自带登录页,被安全层拦掉就再也进不去了。真正的开关只有 yaml 里的 stat-view-servlet.*,而且 enabled 是类级条件(starter 的 DruidStatViewServletConfiguration 带 @ConditionalOnProperty(...enabled, havingValue="true"),无 matchIfMissing),置 false 是根本不注册这个 servlet,不是「注册了但拦住」。
现状:
| profile | enabled | allow 默认 | 口令 |
|---|---|---|---|
prod(:22) | ${DRUID_STAT_VIEW_ENABLED:false} | 空 | 环境变量 |
local(:18)/ dev | true | ${DRUID_STAT_VIEW_ALLOW:127.0.0.1} | 环境变量 |
口令一律走 DRUID_STAT_VIEW_USERNAME / DRUID_STAT_VIEW_PASSWORD,不要写进这三份 yaml(它们入库)。
怎么办:
- 验收时用
curl -s -o /dev/null -w '%{http_code}' http://<host>:48080/druid/sql.json—— prod 期望 404(servlet 没注册),local/dev 从非回环地址访问期望 403。返回 200 就是配置没生效或进程没重启。 - 🔴
allow收成127.0.0.1有个连带后果:Web 管理端「基础设施 → 监控中心 → MySQL 监控」是 iframe 内嵌这个控制台,浏览器直连后端,请求的来源 IP 是使用者那台机器而不是 Web 服务器。跨机器访问时这个页面会 403 白屏——那不是页面坏了,把DRUID_STAT_VIEW_ALLOW设成自己的网段并同时给口令即可。见使用手册 · MySQL 监控。 - 生产上确实要开这个页面,就三件事一起给:
DRUID_STAT_VIEW_ENABLED=true+DRUID_STAT_VIEW_ALLOW=<运维网段>+DRUID_STAT_VIEW_PASSWORD。只给第一个等于把这条坑原地复活。 allow留空与不写在 Druid 里是同一件事(ResourceServlet#init对 null 和空串走同一分支),所以从裸空值改成${VAR:}不会改变语义。
A3. 连接池长跑必然耗尽,而 remove-abandoned 没开
🔴 现象:进程跑久了以后,后台任务和接口开始报
com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 60002, active 20, maxActive 20, creating 0实测证据(2026-10-16,进程已连续运行约 30 小时,elapsed=107855.25s):
_logs/be_run.log里这条例外出现 208 次;_logs/druid-datasource-20261016.json:ActiveCount: 20(=MaxActive)、PoolingCount: 0、WaitThreadCount: 5、NotEmptyWaitCount: 227、NotEmptyWaitMillis: 13620325(累计等待 ≈3.8 小时)、RemoveAbandoned: false;_logs/jstack-20261016-pool-exhausted.txt:5 个线程停在DruidDataSource.pollLast,分别是flowable-bpmn-acquire-async-jobs、flowable-bpmn-acquire-timer-jobs、flowable-bpmn-reset-expired-jobs、scheduling-1、SimpleAsyncTaskExecutor-79;- 整个 dump 里没有任何线程正在 MySQL 的 socket 读上。
最后一条是关键:20 个连接不是「都在忙」,是借出去没还。RemoveAbandoned: false 意味着 Druid 永远不会回收它们,只能重启进程。重启后实测立刻恢复(ActiveCount: 0、PoolingCount: 2、WaitThreadCount: 0)。
为什么会先看到 Flowable 报错:饿死的是后台轮询线程(作业获取、定时任务),它们每隔几秒就要一次连接,最先撞上池上限。用户侧的表现往往是「工作流不推进了」「定时任务不跑了」,而不是「页面打不开」——排查方向很容易被带到 Flowable 或 Quartz 上去。
怎么办:
- 短期:把
remove-abandoned: true+remove-abandoned-timeout: 180(秒)打开,让 Druid 强制回收长借不还的连接,并在日志里打出借用点的堆栈——这是定位真正泄漏处最直接的手段; - 中期:按
remove-abandoned打出的堆栈找到那处借连接不还的代码。重点怀疑对象是事务边界里套了长耗时外部调用(LLM 流式响应、Webhook、文件解析)——@Transactional会在整个方法执行期间持有连接; prod的initial-size/min-idle都是 1(只有dev是 5 / 10),生产并发一进来就要现建连接,配合max-active: 20余量很小,按实际负载上调。
这条是「运行期才会出现」的问题
本地起一次跑几分钟看不到它。它需要小时级的连续运行 + 真实业务流量才会累积到池上限,所以在开发环境里几乎不可能自然复现,只能靠 remove-abandoned 主动暴露。别因为「我这儿没报错」就认为不存在。
A4. application-prod.yaml 是 local 的复制品
现象:application-prod.yaml:242-243
captcha:
enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试;生产 profile 里留着「本地环境」的注释。同文件 :248 的 mock-enable 当初也是从 local 原样抄来的 true(已修,见 A1),Druid stat-view-servlet 同样曾与 local 逐字相同(已修,见 A2)。数据库口令确实是生产的那一套,说明这个文件是认真改过一部分、又漏掉了一部分——而漏掉的正好是三个安全开关。
怎么办:把 application-prod.yaml 和 application-local.yaml 逐行 diff 一遍,确认每一处差异都是有意的。至少 enlev.captcha.enable、enlev.security.mock-enable、Druid stat-view-servlet、initial-size / min-idle 这五处要按生产语义重填。后两处安全项已经填对了(mock-enable false、Druid enabled 默认 false),但它们是靠人工对齐的,没有任何门禁会拦住下一次抄错——新增 profile 时这五处仍要逐个核。🔴 顺带一句:这几处的行号会因为上方加注释而整体位移,引用时以 grep -n 现查为准。
B. 构建与部署
B1. jar 默认跑 local profile
现象:部署到服务器后启动失败在数据源初始化,或者更糟——起来了但连的是 127.0.0.1:3306。
根因:application.yaml:5-6 硬编码 spring.profiles.active: local,打进了 jar。
怎么办:java -jar enlev-server.jar --spring.profiles.active=prod,或 SPRING_PROFILES_ACTIVE=prod。启动日志里搜 The following profile is active 确认。
B2. pnpm build:prod 会把 .env.local 掺进来
🔴 现象:生产包打开登录页,没有滑块验证码。
根因:Vite 的 loadEnv 按 .env → .env.local → .env.${mode} → .env.${mode}.local 顺序合并,后者覆盖前者。本仓的 .env.local 是纳入 git 跟踪的(git ls-files .env.local 有输出),克隆即存在,所以 pnpm build:prod 时它一定参与。.env.prod 里没有 VITE_APP_CAPTCHA_ENABLE,于是 .env.local:31 的 false 盖掉了 .env:12 的 true。
实测:跑完 pnpm build:prod 后,dist-prod/assets/LoginForm-*.js 里是字面量 captchaEnable:`false`;ForgetPasswordForm-*.js、RegisterForm-*.js 同样;SocialLogin-*.js 里是 captchaEnable:!1(构建期折成了布尔常量)。
怎么办:要开验证码就在 .env.prod 里显式写 VITE_APP_CAPTCHA_ENABLE=true。只改 .env 没用,它优先级最低。前端开了后端也得开(enlev.captcha.enable),只开一边会出现「前端弹了滑块、后端不认」。
推广开来看:.env.prod 没定义、.env.local 定义了的所有变量都会中招,不只验证码这一个。加生产配置时先确认 .env.local 里有没有同名项。
B3. 仓库自带的三套部署脚本都跑不通
现象:cd ainb/script/docker && docker compose --env-file docker.env up -d 直接失败。
根因:script/{docker,jenkins,shell}/ 是从上游 ruoyi-vue-pro 带过来的,改名时只替换了字符串,没跟着本仓真实结构调整:
| 脚本 | 坏在哪 |
|---|---|
docker-compose.yml | 引用的 ./enlev-server/、./ainb-ui-admin、./sql/mysql/ruoyi-vue-pro.sql 三个路径都不存在;admin 服务传的 VUE_APP_* 构建参数是 Vue2 时代的,真实前端读 VITE_*;接口前缀写成 /prod-api,实际是 /admin-api |
Jenkinsfile | git url 检出的是上游 gitee.com/will-we/ruoyi-vue-pro.git 的 devops 分支(同文件 :24 的 GITHUB_ACCOUNT 已经改成本仓了,就它没改);cp -f bin/deploy.sh 里的 bin/ 目录不存在 |
deploy.sh | PROFILES_ACTIVE=development,而仓库只有 application-{dev,local,prod}.yaml;BASE_PATH 与 Jenkinsfile 的 APP_DEPLOY_BASE_DIR 是两个不同路径 |
enlev-server/Dockerfile 单独看能用,但基础镜像是 eclipse-temurin:21-jre 而项目 java.version 是 17,且 ENV JAVA_OPTS="-Xms512m -Xmx512m" 对 325 MB 的胖 jar 偏小。
怎么办:要哪套就照构建与部署的清单逐项修,别指望开箱可用。
deploy.sh 的 profile 错法是「静默」的
Spring Boot 找不到 application-development.yaml 时不报错,直接用 application.yaml 的默认值。表现是「部署成功了、健康检查也过了,但连的库不对」,或者启动失败在数据源初始化而报错信息里完全不提 profile。
B4. mvn clean 会被正在运行的 jar 卡住
现象:mvn clean package 报 Failed to delete ...enlev-server.jar。
根因:Windows 文件锁——有 java -jar enlev-server/target/enlev-server.jar 正在跑。
怎么办:
netstat -ano | grep ':48080' | grep LISTENING # 拿 PID
taskkill //f //pid <PID>Git Bash 里 taskkill 的 /f /pid 要写成 //f //pid,否则会被当成路径转换。
B5. 改代码生成模板必须重新打包
现象:改了 enlev-module-infra/src/main/resources/codegen/ 下的 .vm,刷新页面重新生成,产物没变。
根因:模板由 CodegenEngine 通过 Hutool 的 VelocityEngine 从 classpath 读(CodegenEngine.java:9、:333、:433),106 个 .vm 嵌在 BOOT-INF/lib/enlev-module-infra-2026.07-SNAPSHOT.jar 里。外层 jar 的条目列表里搜 codegen/ 返回 0,容易误判成「模板没打进去」。
怎么办:mvn package -pl enlev-module-infra,enlev-server -am -DskipTests 然后重启。
C. 数据库与多租户
C1. 是不是租户表,由「有没有那一列」决定
🔴 现象:PostDO extends BaseDO(不是 TenantBaseDO),但 SHOW CREATE TABLE system_post 里明明有 tenant_id bigint NOT NULL DEFAULT '0'。看着像写错了,其实两边都对。
根因:租户隔离是 TenantLineInnerInterceptor 在 SQL 层按表名加 WHERE tenant_id = ? 的(EnlevTenantAutoConfiguration.java:75-77),跟 DO 继承哪个基类无关。TenantBaseDO 只是多给你一个 Java 字段方便代码里读写。实测全仓 97 个 DO 继承 BaseDO、30 个继承 TenantBaseDO,而库里有 112 张带 tenant_id 的表(共 229 张)——数字对不上是常态。
怎么办:判断一张表要不要租户化,看建表语句里有没有 tenant_id,以及 enlev.tenant.ignore-tables 里有没有登记它。别去看 DO 基类。
反过来也成立:新建表漏了 tenant_id 又没登记 ignore-tables,SQL 层照样会拼上 WHERE tenant_id = ?,报 Unknown column 'tenant_id'。在计量、日志这类「异常被吞」的路径上,表现是整条记录静默丢失,不是接口报错——那种最难查。
详见数据库约定与表总览。
C2. tenant_id 建索引不是硬约定
现象:翻建表语句时发现有的租户表给 tenant_id 建了 idx_tenant_id,有的没有,看不出规律。
实测:94 张带 tenant_id 列的表里只有 26 张给它建了索引,system_post 就只有 PRIMARY。
口径写清楚,免得又对不上数(本页上一版记的是「112 张 / 30 张」,两个数都复现不出来):
-- 分母:有这一列的表
select count(distinct table_name) from information_schema.columns
where table_schema = 'ainb-professional-init' and column_name = 'tenant_id'; -- 94
-- 分子:给这一列建过索引的表(任意索引里出现过即可)
select count(distinct table_name) from information_schema.statistics
where table_schema = 'ainb-professional-init' and column_name = 'tenant_id'; -- 26ruoyi-vue-pro(旧库)同口径也是 94 / 26,所以这不是换库带来的差异。另注意分母(94)与「extends TenantBaseDO 的 DO 数」(实测 33)差得很远 —— 这正是 C1 说的那件事:是不是租户表由列决定,不由 DO 基类决定。
怎么办:按查询量自己判断,不要当成硬约定去「补齐」。真要补,先看这张表的行数和其它索引的区分度——tenant_id 区分度极低(通常就几个值),单独建索引收益有限,更有价值的是 (tenant_id, xxx) 组合索引。
C3. 新增表忘了补 H2 的两份 SQL,报错会指向别处
🔴 现象:给一个 Service 加 BaseDbUnitTest 子类,Spring 启动阶段就失败,报错信息里出现 schema-locations / Cannot execute ... script,看起来像配置问题。
根因:BaseDbUnitTest 靠 src/test/resources/sql/create_tables.sql 建 H2 表结构、clean.sql 在每个测试方法后清数据。新表没登记进这两个文件,H2 里就压根没有这张表。目前只有 bpm / im / infra / system 四个模块有这两份文件(system 的 create_tables.sql 764 行、clean.sql 41 行);ai / agent 模块没有 application-unit-test.yaml,它们的测试绝大多数是纯 Mockito。
怎么办:在 system / infra / bpm 里加表,同步补这两份文件;在 ai / agent 里想加 DB 测试,先补 application-unit-test.yaml + 两份 SQL 三件套。详见测试规范。
C4. 「清空一个字段」必须显式声明 FieldStrategy.ALWAYS
🔴 现象:编辑岗位时把「所属组织」清掉,点确定,提示成功,刷新后组织还在。
根因:MyBatis-Plus 的 updateById 默认策略是 NOT_NULL——实体里为 null 的字段不进 UPDATE 语句。所以「传 null 表示清空」这个语义在默认策略下不存在,SQL 里根本没这一列,数据库自然不动。
怎么办:在 DO 上显式声明:
/**
* 所属组织编号
*
* 允许「清空」:默认 NOT_NULL 策略下 null 字段不会进 UPDATE 语句,
* 用户在表单里把组织清掉将静默失效。
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deptId;PostDO 就是这么写的,回归测试在 PostServiceImplTest#testUpdatePost_clearDeptId(PostServiceImplTest.java:78 附近)。这个用例必须走 H2:Mockito 只能验「Service 调了 updateById」,验不出「Mapper 生成的 SQL 里到底有没有这一列」——这正是它被归到 BaseDbUnitTest 而不是 BaseMockitoUnitTest 的原因。
同样的模式在定制的 GrantDO 上也有。凡是「用户能把它清空」的可空外键字段,都要这么声明一遍。
C5. 逻辑删除字段是 bit(1),别写 = false
现象:手写 SQL 用 WHERE deleted = false,在某些方言下报语法错误。
根因:建表是 deleted bit(1) NOT NULL DEFAULT 0,Java 侧映射成 Boolean。MySQL 会把 false 当 0 认下来,但本项目 profile 里备着 PostgreSQL / 达梦 / 人大金仓 / SQLServer 的连接串(application-{local,prod}.yaml 的 datasource 注释块),换库时这一句就未必成立。
怎么办:手写 SQL 一律 deleted = 0 / deleted = 1。
D. 菜单 / 路由 / 权限
D1. component 路径匹配不上就是白屏,而且零报错
🔴 现象:菜单加好了、授权也给了、点进去整页白屏,浏览器 Console 一条错误都没有,Network 里也没有 404。
根因:src/utils/routerHelper.ts 里把后端给的 component 字符串换成真实组件用的是:
const index = route?.component
? modulesRoutesKeys.findIndex((ev) => ev.includes(route.component))
: modulesRoutesKeys.findIndex((ev) => ev.includes(route.path))
data.component = modules[modulesRoutesKeys[index]]modulesRoutesKeys 是 import.meta.glob('../views/**/*.{vue,tsx}') 的键。component 写错一个字符,findIndex 返回 -1,modulesRoutesKeys[-1] 是 undefined,data.component 也就是 undefined——Vue Router 拿到一个没有 component 的路由记录,不报错,渲染出空。
两个额外的坑叠在上面:
- 匹配用的是
String.includes,不是全等也不是路径解析。component填system/post/index能匹配上../views/system/post/index.vue,但填post/index也能匹配上——于是它可能匹配到另一个模块下的同名文件,白屏变成了「点 A 菜单出来 B 页面」,更难查。 route.component为空时回落到用route.path去匹配。所以「component 不填,让它跟 path 保持一致」这个上游兼容逻辑,在 path 带前导/或多级时会失配。
怎么办:component 一律填 src/views/ 下的相对路径、不带 .vue 后缀、不带前导斜杠,例如 system/post/index。白屏时第一件事是在 Console 里打
JSON.parse(localStorage.getItem('roleRouters') ?? 'null')对着里面的 component 字符串去 src/views/ 下确认文件真的存在、路径真的一字不差。详见新增一个菜单功能端到端。
D2. 直接 INSERT 菜单不会清 Redis 缓存
现象:用 SQL 插了新菜单,接口层还是老样子。
根因:MenuServiceImpl 上的 @CacheEvict(:60、:81、:104、:125)清的是 RedisKeyConstants.PERMISSION_MENU_ID_LIST(permission_menu_ids:{permission})。走 UI(菜单管理页)会经过 Service,缓存被清;直接 INSERT INTO system_menu 绕过了整个 Service 层,缓存原样留着。
前端那边还有一层:views/system/menu/index.vue:345 和 MenuForm.vue:218 会 wsCache.delete(CACHE_KEY.ROLE_ROUTERS)。直接写 SQL 同样绕过它。
怎么办:
- 新增全新权限串通常无感——缓存里没这个 key,第一次查会回源;
- 改已有权限串会读到旧缓存,必须手动清:
DEL permission_menu_ids:<权限串>,或干脆SCAN+DEL掉permission_menu_ids:*; - 前端菜单不刷新,退出重登,或者在 Console 里
localStorage.removeItem('roleRouters')后刷新页面。不需要重登——permission store 是persist: false,刷新即重建路由。
D3. 用超管账号测不出任何权限问题
🔴 现象:新功能自测一切正常,换个普通账号就 403 / 按钮全不见。
根因:super_admin 走短路,压根不查 system_role_menu——RoleCodeEnum.isSuperAdmin 与 GrantDecisionService#isSuperAdmin 两处都有这条捷径;前端 v-hasPermi 对超管也恒为真。
怎么办:验证授权必须换一个非超管账号,并且这个账号的角色要显式勾上新菜单。项目里为多视角截图专门建过独立角色和用户,可以复用。详见新增一个菜单功能端到端。
D4. status = 0 是开启,visible = 0 只是不显示
现象:把菜单「隐藏」了,直接输 URL 还能进去。
根因:CommonStatusEnum.ENABLE = 0、DISABLE = 1——菜单、岗位、字典全用这一套,和直觉相反。visible 只影响侧边栏渲染,路由照样注册。
怎么办:想彻底禁止访问,用 status = 1(关闭)或者直接收回授权,不要指望 visible = 0。
D5. 菜单图标只认四个前缀
现象:菜单图标位置一片空白。
根因:只有 ep:(Element Plus)、fa: / fa-solid:(Font Awesome)、svg-icon:(本地 src/assets/svg)是本地图标;其它写法(比如裸的 Iconify 名)要联网去拉 Iconify 的图标数据。
怎么办:内网 / 离线部署一律用上面四个前缀。菜单管理页的图标选择器里能选出来的都是安全的。
E. 代码生成器
E1. 代码生成器的「上级菜单」不填,SQL 里会是字面量
🔴 现象:生成完下载 zip,里面的 sql.sql 有这么一行:
'岗位管理', '', 2, 0, ${table.parentMenuId},${table.parentMenuId} 原样出现在产物里。执行时 MySQL 报语法错误,或者更糟——被当成用户变量解析成 NULL,菜单挂到了根上。
根因:Velocity 对未解析的引用默认原样输出。codegen/sql/sql.vm 的 :33、:61、:94、:127、:162 五处都用了 ${table.parentMenuId},而这个值来自生成配置页的「上级菜单」,不填就是 null。
怎么办:生成前必须在配置页选一个上级菜单。已经生成了就在 SQL 里手改。详见新增一个菜单功能端到端。
E2. 「导入表」这一步会用登录用户的昵称当 author
现象:点「导入」建代码生成配置时报
Field 'author' doesn't have a default value根因:infra_codegen_column / infra_codegen_table 的 author 列是 NOT NULL 且无默认值,插入时取的是当前登录用户的昵称。昵称为空(或该字段没取到)就撞库。
怎么办:先在「个人中心」把昵称填上,再回来导入表。
E3. 生成器不会写你的工程,只有 preview 和 download
现象:以为点「生成」就能把代码写进 src/main/java,找了半天没有这个按钮。
根因:CodegenController 全文 160 行,只有两个接口:GET /infra/codegen/preview(返回渲染后的文件内容给前端展示)和 GET /infra/codegen/download(ZipUtil.zip 打成 codegen.zip 下载)。没有任何写盘接口。
怎么办:下载 zip、解压、逐个文件对着仓库既有代码改一遍再放进去——见 E4。
E4. 生成器产物与仓库既有代码有 7 处风格分歧
现象:把生成的 14 个文件直接拷进工程,编译能过,但和邻居长得不一样,评审时被打回。
实测一次真实生成(岗位表,14 个文件)与仓库既有 system/post 代码的差异:
| 项 | 生成器产出 | 仓库既有写法 |
|---|---|---|
| 导出接口名 | exportPostExcel | export(@GetMapping("/export-excel")) |
| 导出文件名 | 按类名拼 | 手写中文,如 "岗位数据.xls",sheet 名 "岗位列表" |
| 包位置 | .../dept/ 或 .../<module>/ 按表名推 | 岗位代码实际在 dept 包下(dal/dataobject/dept/PostDO.java),不在 post 包 |
| 错误码 | 从 1 开始顺序排 | 按模块段位分配,岗位是 1_002_005_000 ~ 1_002_005_003 |
| 字段注释 | 复制列 COMMENT | 会补「为什么」,如 FieldStrategy.ALWAYS 那段 |
| 前端 API 函数名 | exportXxx | exportXxx 但 URL 常与后端不一致,需对齐 |
| 单元测试 | 生成 XxxServiceImplTest 骨架,无自定义用例 | 关键分支(如清空字段)必须有 H2 用例 |
怎么办:把生成结果当脚手架,不当成品。逐文件对照接口约定与 CRUD 骨架和同模块的既有实现改。生成后必改的对照表在新增一个菜单功能端到端里。
F. 测试与工程协作
F1. 前端 formRules 和后端 VO 校验不对齐
现象:表单里「岗位顺序」留空,前端放行,点确定后右上角弹红条(400),字段下面却没有红字提示。
根因:src/views/system/post/PostForm.vue 的 formRules 只声明了 name、code、status、remark 四项,没有 sort;而后端 PostSaveReqVO 上 sort 是 @NotNull。
怎么办:加字段时两边一起加。红条 vs 字段红字的差别就是「后端拦下的」vs「前端拦下的」——看到红条先去对 VO 的校验注解。
F2. ts:check 之前必须先跑过一次 pnpm dev
现象:刚克隆下来跑 pnpm ts:check,报一堆「找不到组件 XXX」。
根因:src/types/auto-*.d.ts 被 gitignore,只由 dev server 生成(build/vite/index.ts 里 dts: !isBuild)。
怎么办:pnpm dev 起一次让它生成,再 ts:check。ts:check 走增量缓存(node_modules/.cache/vue-tsc/tsconfig.tsbuildinfo),结果可疑时 pnpm ts:check:clean。
F3. vitest 冷启动会偶发超时
现象:pnpm test:unit 第一次跑,commentLineRefBan.spec.ts 报 Error: Test timed out in 5000ms,整套 1 failed。单独跑这个文件 1129 ms 就过;紧接着再跑一次全量,59 files / 1280 tests 全绿,4.14 s。
根因:这个 spec 要读全仓源码文件做正则扫描,冷启动时 CPU 争用(同一时刻可能还有 tsc、后端进程在跑)把它顶过了 5000 ms 的默认单测超时。
怎么办:重跑一次即绿,不是代码问题。要根治就给这类扫描型 spec 单独放宽超时(it('...', { timeout: 20000 }, ...))。别把它当成「测试不稳定所以先跳过」。
F4. pnpm test:e2e 是坏的,不要运行也不要补
现象:pnpm test:e2e 报找不到配置。
根因:package.json:23-24 里 test:e2e / test:e2e:headed 指向 playwright.config.ts 和 tests/e2e/,两者在仓库里都不存在。vitest.config.ts 的注释里还提到 Playwright 那半,也已经不存在了。
怎么办:不运行、不补全。前端验证靠 pnpm test:unit(59 spec / 1280 用例)+ 手工点。
F5. 后端 3384 个文件的 blame 都停在同一个「项目初始化」提交
🔴 现象:想知道某行后端代码是谁、为什么这么改,git blame 指向 78027a03ce feat(系统):项目初始化,再往前什么都没有。
实测(2026-10-25,在 ainb/ 下):
git log --oneline | wc -l # → 26
git show --numstat --format='' 78027a03ce | wc -l # → 4136 个文件、574044 行 insert
comm -23 <(git show --numstat --format='' 78027a03ce | cut -f3 | sort) \
<(git diff --name-only 78027a03ce HEAD | sort) | wc -l # → 3384整个后端历史被压成了一个 4136 文件的初始化提交,其中 3384 个至今没动过(blame 只能到那一刀),另外 752 个在 2026-10-18 之后被改过、已有可查历史。
⚠️ 这条坑的旧版本写的是「ainb/ 里只有 3 个 commit,git log 什么也查不到」——那在当时是真的,但后端从 2026-10-18 起已经在正常小步提交,现在 26 个。所以 blame 不是「没用」,是分区可用:先 git log --oneline -- <path> 看这文件动过没有,动过就能查到原因。
三仓的 git 考古可用度完全不同:后端只有二十几个提交且大半文件止于初始化那一刀,另两仓各有数千个提交、历史完整,而移动端那数千个里只有 23 个是本项目的,不按作者过滤就全是上游。那张对比表只在仓库结构与改名约定 · 代码在哪有一份。
🔴 本页以前也抄了一份,当天就分叉了:Web 仓因为一个注释修复多了一个 commit,那边改成 5821、这边还留着 5820,而没有任何门禁会发现。所以现在只指路不重述——提交数本来就是活动值,抄一份等于给自己造一处必然过期的拷贝。
怎么办:
- 那 3384 个文件的「为什么」只能靠代码注释和文档,别指望 git。这也是本仓对注释要求偏高、并且有
CommentLineRefBanTest这类守卫测试的原因之一。 - 判断某功能是上游自带还是本项目定制,
@author是唯一可靠信号:grep -rho '@author .*' --include='*.java' . | sort | uniq -c | sort -rn→ 「走进新时代」975 个文件、「芋道源码」636 个。🔴 本项目自己写的比上游带来的还多,跟「后端基本是上游代码」的直觉相反,别凭对 yudao 的记忆下结论。 - 从现在起小步提交,别再压。提交信息格式是
type(范围):中文描述,全角括号和全角冒号。
F6. 注释里写 camelCase键:值,整套前端单测会红
🔴 现象:只改了两行注释,pnpm test:unit 报 1 failed | 1279 passed (1280),红的是 commentLineRefBan.spec.ts,报错里指着你刚写的那句注释。
实例(999135233 修的就是它):9e1025c35 在 src/views/system/menu/index.vue 与 src/views/system/navNode/index.vue 的列宽注释里写了 flexShrink:0,被守卫的 A 型规则判成「标识符 + 冒号 + 行号」的位置引用——flexShrink 命中 camelCase 形状 /^[a-z]+[A-Z][A-Za-z0-9]*$/,0 命中行号。整套单测从那次提交起红了一天,因为提交时没跑 pnpm test:unit。
根因:守卫刻意不做标识符白名单。它的设计注释里写明取舍是「CSS 噪声零白名单」——白名单要么漏放真违规,要么需要人维护,两者都比现在的假阳性贵。所以放宽守卫是错的修法。
怎么办:
- 用等号:
flexShrink=0。这不是绕路,守卫的报错信息里本来就写了这条出路——「描述枚举/编码值 → 用等号,例如type=11」。同一段注释里的fixed=false早就是这个写法,改完两处一致。 - 想引用代码位置就写「类名#方法名」或「文件相对路径」,不写行号(行号必然漂移,守卫存在的意义就是这个)。
- 别去改
commentLineRefBan.spec.ts让它放过 camelCase——那条规则正是用来抓MyBatisUtils:50这类引用的,放宽它等于同时放走真违规。 - 提交前端改动前跑一次
pnpm test:unit(热缓存 4 s,成本极低)。这条坑的代价从来不是「难修」,是「红了一天没人发现」。
后端那条 CommentLineRefBanTest 更宽:正则是 [A-Za-z_][A-Za-z0-9_.#]*:\d+、零白名单(Java 注释里没有 CSS,不需要像前端那样收窄语法)。但它只扫 enlev-module-agent/src/main/java 一个模块,写在别处的 Java 注释里不撞红——别把「后端没报」当成「这么写没问题」。
G. 移动端(专页,本节只指路)
移动端专属的坑有 24 条,单独成页:移动端坑位(编号 M1–M24,与本页的编号 A1–F6 是两套互不复用的空间)。
本节不复制那些条目,只把其中标 🔴 的列成下表,好让「上线前过一遍」这件事能在一页里做完。复制一份等于制造第二处会漂移的拷贝:同一个坑改一次要改两处,而条目正文的分叉没有门禁能发现(check-pages.cjs 只守编号范围与「N 条(编号 …)」这类可机械核对的声明,守不了语义)。
| 症状 | 编号 | 严重度 |
|---|---|---|
| 列表页永久转圈,控制台一条未捕获 rejection | M3 | 高 |
| 跳转失败一律提示「页面不存在」,排查方向被带偏 | M5 | 中 |
| 登录页的默认租户与账号口令被内联进了生产包(已修) | M9 | 致命 |
| 三个身份标识仍是上游的,小程序会传到上游账号下 | M18 | 致命 |
请求加密开关是 true,但没有任何调用方传 isEncrypt | M19 | 中 |
反过来也成立:跨端通用的坑(数据库与多租户、菜单与权限、后端构建部署、代码生成器、测试协作)仍然只在本页,移动端章不重复记。两边都出现的那一类——例如「自动生成的声明文件不入库,新克隆先跑一次 dev」,Web 侧是 F2、移动端侧是 M21——各自只写自己那一端的差异。
还有一类「不是坑,是刻意如此」
下面这些看着像 bug,实际是有意为之,不要顺手修:
| 现象 | 为什么是刻意的 |
|---|---|
enlev-module-im 已连目录一起删除(12347efcd1,305 个文件) | 不在 reactor 里,-pl enlev-module-im 必然失败——不是因为被注释,是因为不存在。⚠️ 本手册曾有 10 处写「目录还在、别顺手修好」并引用「根 pom.xml:19」,而那行现在是 <module>enlev-module-ai</module>。已全部改正,权威清单见后端架构与模块依赖 |
enlev-module-agent 在自己 pom 里本地钉住 spring-ai-bom(:38-44) | 为了覆盖 spring-ai-alibaba-graph-core 的传递依赖版本 |
src/lib/form-create/、src/lib/form-designer/ 是源码副本而非 npm 依赖 | 要求与上游保持零 diff,已在 ESLint 中排除。不要修改、重排格式或「顺手优化」,见 src/lib/form-create/VENDOR.md |
vite.config.ts 里 alias 的顺序 | 顺序敏感,改动前先读注释 |
.env.local 纳入 git 跟踪 | 不合常理但确实如此。不要加进 .gitignore,也不要把值搬到别处(但它会掺进生产构建,见 B2) |
RedisTestConfiguration 里有个空的 catch (Exception ignore) {} | 内嵌 jedismock 关闭时的既有异常,别把它「修好」成抛异常 |
service.ts 的业务错误码分支同时 reject(new Error(msg)) 和弹 ElNotification | C2 修的是「调用方拿不拿得到」,不是「用户看不看得见」。别以为 catch 里有了就把通知删掉 |
代码里不允许出现 文件:行号 形式的引用 | 有守卫测试(后端 CommentLineRefBanTest、前端 commentLineRefBan.spec.ts)扫源码原文,写了就撞红。实测已经抓到过 100 处真实漂移。写「类名#方法名」,不写行号 |
本手册在另一个仓库 xpy-blog/ | 所以手册里可以写行号(它不会被那两个守卫扫到),但写进 ainb/ 或 src/ 的注释里就不行 |
移动端 src/components/yudao-ui/ 目录名带 yudao、仓内还留着一整个 unibest docs/ 站点 | 都是刻意保留:前者被 easycom 规则与自动生成的组件声明双重引用,改名牵连 56 个文件且无收益;后者删除不可逆。见 M22 |
相关页面
- 移动端坑位:移动端专属的 24 条(编号
M1–M24),本页 G 节只指路; - 构建与部署:产物、profile 矩阵、三套脚本的完整问题清单;
- 新增一个菜单功能端到端:从建表到菜单授权,14 条贴着流程的坑;
- 测试规范:四种测试形态怎么选,源码扫描式守卫怎么写;
- 数据库约定与表总览:公共字段、逻辑删除、多租户白名单;
- 接口约定与 CRUD 骨架:五件套每一层的写法;
- 路由与权限:
v-hasPermi、@PreAuthorize与定制授权引擎; - 本地环境搭建:第一次跑起来,含 mock token 的正确用法。
