一套固定资产管理系统(EAM),从后端 Spring Boot、前端 Vben/Vue3 到 Android Compose App 三端同时推进,覆盖资产八大业务模块(领用 / 借用 / 调拨 / 盘点 / 维修 / 报废 / 折旧 / 租户初始化)。本文不堆概念,只把开发过程中真实踩过的坑、对应的代码级根因和修复方式拉出来复盘,供同类项目避雷。
一、技术栈概览
| 端 | 技术栈 | 说明 |
|---|---|---|
| Server | RuoYi-Vue-Plus 5.6.0 + MyBatis-Plus 3.5.14 + Java 17 + Spring Boot 3.x | 多租户(TenantEntity)、Sa-Token 鉴权、代码生成器 |
| Web | Vben 5.x + Vue3 + vxe-table 4.18 + antdv + echarts + Vite + pnpm | VITE_PORT=5666,启动用 pnpm dev |
| Android | Kotlin + Jetpack Compose + Retrofit + kotlinx.serialization | 包名 com.cxtech.eam,Splash + Compose 模板 |
| 基础设施 | Docker(host 网络)+ MySQL 8 + Redis 7 + MinIO + SnailJob 1.9.0 | 线上 saas.****.cn,prod-api 走 HTTP |
业务上最棘手的地方不是单端技术,而是三端对同一张 asset_info 表的协同语义:同一份资产在主数据、流转单据、App 扫码、折旧计算中要保持一致,任何一端的主键 / 字段口径偏差都会放大成脏数据。
二、后端(Server)踩坑
2.1 资产主键冲突:Duplicate entry '0' 让 App 登记功能直接坏掉
现象:App 端做资产登记(新增),第一次能写进去,之后所有登记全部报 Duplicate entry '0' for key 'PRIMARY'。
根因:MyBatis-Plus 主键策略与"App 默认空串"双重叠加。
- 表
asset_info没有AUTO_INCREMENT,原@TableId用IdType.NONE默认走数据库自增 → 实际没自增; - App 登记表单里
assetId默认是空串"",传到 Kotlin 转Long得到0; - MyBatis-Plus 看到主键字段"有值(0)"就不会走
ASSIGN_ID雪花生成,于是直接把asset_id=0插入;第一条建出id=0的脏数据,后续全部撞主键。
代码里现在留下了明确的修复注释(AssetInfo.java):
/**
* 资产ID
* ⚠️ 2026-08-13 修复:必须 ASSIGN_ID(雪花)——原 NONE 默认走库自增,但 asset_info 表无 AUTO_INCREMENT,
* App 登记传 asset_id=0 直接插入(第一次建出 id=0 脏数据,之后必 Duplicate entry '0',登记功能实际是坏的)
*/
@TableId(value = "asset_id", type = IdType.ASSIGN_ID)
private Long assetId;/**
* 资产ID
* ⚠️ 2026-08-13 修复:必须 ASSIGN_ID(雪花)——原 NONE 默认走库自增,但 asset_info 表无 AUTO_INCREMENT,
* App 登记传 asset_id=0 直接插入(第一次建出 id=0 脏数据,之后必 Duplicate entry '0',登记功能实际是坏的)
*/
@TableId(value = "asset_id", type = IdType.ASSIGN_ID)
private Long assetId;App 端同步把空串兜底成 null,让后端收到 null 才会触发雪花生成(AssetExtensions.kt):
// ⚠️ 2026-08-13 修复:CREATE 模式 assetId 默认 ""(空串)→ 传后端转 Long 0
// → MyBatis-Plus 主键有值(0)不走全局 ASSIGN_ID 生成 → Duplicate entry '0'。
// ifBlank { null } 让后端收到 null → 全局 idType=ASSIGN_ID 雪花生成
assetId = assetId.ifBlank { null },// ⚠️ 2026-08-13 修复:CREATE 模式 assetId 默认 ""(空串)→ 传后端转 Long 0
// → MyBatis-Plus 主键有值(0)不走全局 ASSIGN_ID 生成 → Duplicate entry '0'。
// ifBlank { null } 让后端收到 null → 全局 idType=ASSIGN_ID 雪花生成
assetId = assetId.ifBlank { null },教训:前后端 ID 口径必须钉死——前端空值一律传 null,后端主键策略强制 ASSIGN_ID,绝不能依赖数据库自增(尤其分库分表 / 多租户场景)。
2.2 资产变更日志拦截器被"批量 IN 更新"悄悄绕过
需求:所有对 asset_info 的字段变更都要自动落一条变更日志(AssetChangeLogInterceptor,MyBatis-Plus 拦截器,执行前后对比 before/after 差异)。
坑:借用在途、领用审批通过等业务,是批量更新资产状态——用 wrapper.in("asset_id", ids) 一次性更新多条。原拦截器只解析单值 asset_id = ?:
// 原实现只能识别:asset_id = #{ew.paramNameValuePairs.xxx}
Matcher eqMatcher = Pattern.compile("asset_id\\s*=\\s*#\\{ew\\.paramNameValuePairs\\.(\\w+)\\}").matcher(sqlSegment);// 原实现只能识别:asset_id = #{ew.paramNameValuePairs.xxx}
Matcher eqMatcher = Pattern.compile("asset_id\\s*=\\s*#\\{ew\\.paramNameValuePairs\\.(\\w+)\\}").matcher(sqlSegment);批量场景的 sqlSegment 是 asset_id IN (?,?),正则匹配不到,于是 extractAssetIds 返回空 → 审批通过不记任何变更日志,资产履历直接缺一块。
修复:同时支持单值与 IN,并反射取出 LambdaUpdateWrapper 内部的 paramNameValuePairs 还原出真实 id 列表:
// b. 批量:asset_id IN (#{ew.paramNameValuePairs.xxx}, ...)
Matcher inMatcher = Pattern.compile("asset_id\\s+IN\\s*\\(([^)]*)\\)").matcher(sqlSegment);
// ... 逐个抽出 key,再从 paramNameValuePairs 取 Long 值// b. 批量:asset_id IN (#{ew.paramNameValuePairs.xxx}, ...)
Matcher inMatcher = Pattern.compile("asset_id\\s+IN\\s*\\(([^)]*)\\)").matcher(sqlSegment);
// ... 逐个抽出 key,再从 paramNameValuePairs 取 Long 值教训:MyBatis-Plus 拦截器拿参数不能只认"自己写的那一种 SQL 形态"。批量更新、LambdaWrapper、注解式 Wrapper 生成的 sqlSegment 形态差异很大,解析逻辑要覆盖全。
2.3 OSS 预签名 URL 过期时间硬编码 7 天
私有桶(private)文件读取需要临时签名 URL。原代码写死 120s,实际使用发现太短,于是直接改成硬编码 7 天,且没有配置项(SysOssServiceImpl.java):
// 仅修改桶类型为 private 的URL,临时URL时长为120s
// 120s太短了,设置7天
if (AccessPolicyType.PRIVATE == storage.getAccessPolicy()) {
oss.setUrl(storage.createPresignedGetUrl(oss.getFileName(), Duration.ofDays(7)));
}// 仅修改桶类型为 private 的URL,临时URL时长为120s
// 120s太短了,设置7天
if (AccessPolicyType.PRIVATE == storage.getAccessPolicy()) {
oss.setUrl(storage.createPresignedGetUrl(oss.getFileName(), Duration.ofDays(7)));
}坑:改过期时长要改代码、重新发版;不同客户对临时 URL 有效期诉求不同(安全合规角度 7 天偏长)。正确做法是抽成配置(application.yml + @Value 或 OSS 配置表字段),下发热更新。这个坑目前还在系统里,是后续重构点。
2.4 Sa-Token 权限码匹配:超管 *:*:* 放不过"段数不对"的权限码
系统存在两段权限码(如 system:backup)和四段权限码(如 asset:report:*),但 Sa-Token 默认的 vagueMatch 要求段数一一对应——*:*:* 只能匹配三段,超管账号反而被卡死。
自定义了匹配策略(SaTokenConfig.java):* 或 *:*:* 直接放行任意段数,其余按 : 分段、单段 * 通配:
static boolean permMatch(String pattern, String permission) {
if ("*".equals(pattern) || "*:*:*".equals(pattern)) return true; // 超管全权限
String[] pp = pattern.split(":", -1);
String[] sp = permission.split(":", -1);
if (pp.length != sp.length) return false; // 段数必须一致
for (int i = 0; i < pp.length; i++) {
if (!"*".equals(pp[i]) && !pp[i].equals(sp[i])) return false;
}
return true;
}static boolean permMatch(String pattern, String permission) {
if ("*".equals(pattern) || "*:*:*".equals(pattern)) return true; // 超管全权限
String[] pp = pattern.split(":", -1);
String[] sp = permission.split(":", -1);
if (pp.length != sp.length) return false; // 段数必须一致
for (int i = 0; i < pp.length; i++) {
if (!"*".equals(pp[i]) && !pp[i].equals(sp[i])) return false;
}
return true;
}顺带做了安全加固:common-satoken.yml 里 is-read-cookie: false(关闭 cookie 鉴权,从根源杜绝 CSRF),token-prefix: "Bearer"。
教训:权限码设计要尽早固定"段数规范",并在基座层统一匹配逻辑;否则超管放行这种基础能力会被自定的权限模型坑。
2.5 BaseMapperPlus 的 insert 重载歧义
RuoYi 自定义 BaseMapperPlus<T,V> 扩展了 insert(T)(单条)和 insert(Collection<T>)(批量)两个重载。Service 层约定 insert/deleteByIds 返回 Boolean。坑在于:传入 Collection 单元素时,编译器可能在单条与批量重载间歧义,且批量走 Db.saveBatch 返回的是"是否成功触发"而非逐条结果。调用方别想当然拿返回值当"插入条数"。
三、Web(Vben)踩坑
3.1 Vben 5.x 的弹窗是 Radix Dialog,不是 antd Modal
这是 UI 自动化测试里最容易被绊的一处。Vben 5.x 的 Modal 底层是 Radix Dialog,DOM 上表现为:
<div role="dialog" data-state="open"> ... </div><div role="dialog" data-state="open"> ... </div>老选择器 .ant-modal:visible 直接失效。所有 Playwright 脚本里 modal 选择器统一成:
// web/scripts/ui-common.mjs
export const MODAL_SEL = '[role="dialog"][data-state="open"]';// web/scripts/ui-common.mjs
export const MODAL_SEL = '[role="dialog"][data-state="open"]';教训:UI 自动化断言一定要先看真实 DOM 结构,别拿旧版(antd Modal)的选择器硬套。Vben 升级到 5.x 后这套选择器是全项目 UI 测试脚本的基石。
3.2 vxe-table 4.18 树形展开要用 setAllTreeExpand
资产分类、部门、区域都是树形结构。展开/折叠整棵树不能用老 API,要走 grid 实例的 setAllTreeExpand(menu-select-table.vue):
tableApi.grid?.setAllTreeExpand(expand);tableApi.grid?.setAllTreeExpand(expand);坑:tableApi.grid 可能为 undefined(表格未渲染完就调用),一律加可选链 ?.,并在数据加载完成后再触发。
3.3 OSS endpoint "裸域名" vs 前端 addonBefore 自动补前缀
线上 OSS endpoint 存的是裸域名 saas.****.cn(无 http:// 前缀),而后端拼 URL、前端展示时需要在前面补协议。前端 OSS 配置表单用 addonBefore 自动补(oss-config/data.tsx):
addonBefore: () => (formModel.isHttps === 'Y' ? 'https://' : 'http://'),addonBefore: () => (formModel.isHttps === 'Y' ? 'https://' : 'http://'),坑:历史数据里 endpoint 可能存过完整 URL(带 http://),表单展示时要兼容"已带前缀"的情况,否则会拼出 https://http://...。后端 SysOssServiceImpl 同样按裸域名处理。两端口径必须对齐,否则图片/附件 URL 一半打不开。
3.4 前端 ID 一律 String,跳转详情用 numeric assetId 而非 assetCode
前端所有 id 用 String(避免 JS 大数精度丢失 Long),但跳转资产详情页用的是数值型 assetId,不是 assetCode——这是和列表展示(显示 assetCode)容易混的点,路由参数和接口入参要分清。
四、Android(Compose)踩坑
4.1 架构:Kotlin + Compose + Retrofit + kotlinx.serialization
App 采用 MVI 风格(UiAction / UiState / ViewModel),各业务模块(领用、借用、调拨、维修、报废、盘点)共用一套 initCreate() 表单初始化模板。kotlinx.serialization 做 JSON 解析,和 Retrofit 配合要注意 @SerialName 与后端字段命名(驼峰/下划线)一致,否则 assetId 之类字段静默丢失。
4.2 ProGuard / R8 混淆
项目里有多份 proguard-rules.pro(app/app、core/datastore、core/rfid、core/scan 等),因为不同 module 混淆需求不同。坑点:
- Sa-Token / JWT 相关类、Retrofit 接口、kotlinx.serialization 生成的序列化器必须 keep,否则 release 包运行时崩溃或解析失败;
- DataStore 的
Serializer实现类被混淆后文件名映射出错,登录态读取异常。
教训:混淆规则按 module 收敛,且 release 包必须真机跑一遍核心链路(登录 → 首页 → 业务操作),不能只信 debug。
4.3 DataStore 备份回退,实现"一次登录复用"
自动化测试有 51 个用例,如果每个用例都重新登录,光登录就要几十次。方案是用 DataStore 做登录态备份,测试前置把已登录的 DataStore 文件回退(restore)进去,跑完再清。代码注释里点出了关键陷阱(ErrorHandlingFlowTest.kt):
new 出来的实例清 DataStore 不影响真实实例缓存 → 请求仍带旧 token 不触发 401
教训:DataStore 是单例 + 内存缓存,测试里"局部 new 一个去清"是无效的,必须通过真实文件回退或进程级清理。
五、部署与基础设施踩坑
5.1 Docker host 网络 + 双实例 + JVM 堆限制防 OOM
线上是 7.2G 内存的机器跑 4 个 Java 服务(server1 / server2 / monitor / snailjob)。compose 里明确注释:
⚠️ 2026-08-18 全量部署:7.2G 机器 4 个 java 服务必须限制堆,否则 JVM 默认堆(物理1/4) OOM kill(server2 exit 137 实测)
cxtech-server1:
environment:
SERVER_PORT: 8080
JAVA_OPTS: "-Xmx640m -Xms256m"cxtech-server1:
environment:
SERVER_PORT: 8080
JAVA_OPTS: "-Xmx640m -Xms256m"坑:JVM 默认堆是物理内存 1/4,多实例同机不限位必 OOM(exit code 137)。每个服务显式 -Xmx 是生存底线,且双实例用不同端口 + 前置负载。
5.2 SnailJob 1.9.0 做数据库备份
备份链路:mysqldump + SnailJob cron 调度,任务 SQL 落地 server/script/sql/cxtech_job.sql 入 git 可追溯,触发端点 /system/backup/run-internal 配 X-Backup-Token,后端镜像装 mysql-client。备份文件只保留一份,回滚依赖 git。SnailJob 的 TaskTypeEnum:CRON=3 / SCHEDULED_TIME=2 / POINT_IN_TIME=5 / WORK_FLOW=99。
教训:备份脚本和 SQL 必须进版本库,保证"换台机器也能一键复现",而不是散落在某台服务器上。
5.3 测试/本地启动环境会劫持 server.port
在 WorkBuddy 沙箱等容器化执行环境里,会注入 SERVER__PORT=0 这类环境变量。Spring Boot 的 relaxed binding 会把 SERVER__PORT 映射到 server.port,导致应用绑到 0 端口(随机端口)或直接起不来。本地/测试启动 Spring Boot 必须显式设置 server.port,不能依赖默认值,也不能假设环境没注入奇怪的环境变量。
5.4 生产 prod-api 走 HTTP,OSS endpoint 裸域名
生产为了省证书和简化链路,prod-api 走 HTTP(非 HTTPS),OSS 用裸域名。这跟本地 dev(HTTPS + 完整 endpoint)是两套口径,前端 baseURL、OSS 拼接逻辑要按环境区分清楚,否则"本地好使线上挂"。
六、工程方法论沉淀
几个让这个项目能快速迭代、又不至于乱掉的约定,一并记下来:
- 命名规范:VO/BO/DTO 后缀统一小写
o/b/d(XxxVo/XxxBo/XxxDto),按 Google Java Style 与 RuoYi 基座。 - 测试不是走过场:目标覆盖率 90%+,用 mutation testing 验证"不是假绿";按业务流逐条跑(领用 → 审批 → 解锁 → 履历),不全量回归,便于单点失败定位;Web 有
ui-test.mjs冒烟 + 业务对账,Android 有 instrumented 真机回归。 - 折旧模块迁移:现行查询时计算、无
asset_depreciation明细表,已规划"查询时计算 → 入库时计算"迁移路径(建明细表、按期生成、月结/年结可追溯),回填策略已记录。 - 改动先报告、按逻辑原子提交、部署前必确认:任何部署(含排查时的验证性部署)都先说一声,绝不自行连发;发布后同步更新日志。
小结
EAM 这种"主数据 + 多业务流转 + 三端协同"的系统,坑大多不在某个炫技框架,而在边界语义的一致性:
- 主键策略(ASSIGN_ID vs 自增 vs 空值)决定了能不能写进数据;
- 拦截器对 SQL 形态的覆盖度决定了审计完不完整;
- 前端 DOM 结构(Radix vs antd)、混淆规则、环境端口注入,决定了"能不能跑起来、测不测得到";
- 而 Docker 堆限制、备份入 git,决定了"线上稳不稳、出事能不能回"。
把这几条钉死,三端协同的复杂度就可控了。
Hello Yu