Skip to content

一套固定资产管理系统(EAM),从后端 Spring Boot、前端 Vben/Vue3 到 Android Compose App 三端同时推进,覆盖资产八大业务模块(领用 / 借用 / 调拨 / 盘点 / 维修 / 报废 / 折旧 / 租户初始化)。本文不堆概念,只把开发过程中真实踩过的坑、对应的代码级根因和修复方式拉出来复盘,供同类项目避雷。

一、技术栈概览

技术栈说明
ServerRuoYi-Vue-Plus 5.6.0 + MyBatis-Plus 3.5.14 + Java 17 + Spring Boot 3.x多租户(TenantEntity)、Sa-Token 鉴权、代码生成器
WebVben 5.x + Vue3 + vxe-table 4.18 + antdv + echarts + Vite + pnpmVITE_PORT=5666,启动用 pnpm dev
AndroidKotlin + 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,原 @TableIdIdType.NONE 默认走数据库自增 → 实际没自增;
  • App 登记表单里 assetId 默认是空串 "",传到 Kotlin 转 Long 得到 0
  • MyBatis-Plus 看到主键字段"有值(0)"就不会走 ASSIGN_ID 雪花生成,于是直接把 asset_id=0 插入;第一条建出 id=0 的脏数据,后续全部撞主键。

代码里现在留下了明确的修复注释(AssetInfo.java):

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):

kotlin
// ⚠️ 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 = ?

java
// 原实现只能识别: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 列表:

java
// 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):

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):**:*:* 直接放行任意段数,其余按 : 分段、单段 * 通配:

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.ymlis-read-cookie: false(关闭 cookie 鉴权,从根源杜绝 CSRF),token-prefix: "Bearer"

教训:权限码设计要尽早固定"段数规范",并在基座层统一匹配逻辑;否则超管放行这种基础能力会被自定的权限模型坑。

2.5 BaseMapperPlusinsert 重载歧义

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 上表现为:

html
<div role="dialog" data-state="open"> ... </div>
<div role="dialog" data-state="open"> ... </div>

老选择器 .ant-modal:visible 直接失效。所有 Playwright 脚本里 modal 选择器统一成:

js
// 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 实例的 setAllTreeExpandmenu-select-table.vue):

js
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):

ts
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.proapp/appcore/datastorecore/rfidcore/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 实测)

yaml
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-internalX-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 拼接逻辑要按环境区分清楚,否则"本地好使线上挂"。


六、工程方法论沉淀

几个让这个项目能快速迭代、又不至于乱掉的约定,一并记下来:

  1. 命名规范:VO/BO/DTO 后缀统一小写 o/b/dXxxVo / XxxBo / XxxDto),按 Google Java Style 与 RuoYi 基座。
  2. 测试不是走过场:目标覆盖率 90%+,用 mutation testing 验证"不是假绿";按业务流逐条跑(领用 → 审批 → 解锁 → 履历),不全量回归,便于单点失败定位;Web 有 ui-test.mjs 冒烟 + 业务对账,Android 有 instrumented 真机回归。
  3. 折旧模块迁移:现行查询时计算、无 asset_depreciation 明细表,已规划"查询时计算 → 入库时计算"迁移路径(建明细表、按期生成、月结/年结可追溯),回填策略已记录。
  4. 改动先报告、按逻辑原子提交、部署前必确认:任何部署(含排查时的验证性部署)都先说一声,绝不自行连发;发布后同步更新日志。

小结

EAM 这种"主数据 + 多业务流转 + 三端协同"的系统,坑大多不在某个炫技框架,而在边界语义的一致性

  • 主键策略(ASSIGN_ID vs 自增 vs 空值)决定了能不能写进数据;
  • 拦截器对 SQL 形态的覆盖度决定了审计完不完整;
  • 前端 DOM 结构(Radix vs antd)、混淆规则、环境端口注入,决定了"能不能跑起来、测不测得到";
  • 而 Docker 堆限制、备份入 git,决定了"线上稳不稳、出事能不能回"。

把这几条钉死,三端协同的复杂度就可控了。