老炮踩坑录 · V01 · 老炮视野系列
· 基于「企业融合评估系统」真实源码
· 一个真实跑了两年多的生产项目,我从头到尾审了一遍代码。有让我眼前一亮的地方,也有让我直摇头的地方。不吹不黑,如实记录。
前言
我做了18年Java开发,看过大大小小的项目没有一百也有八十个。
但像这样——人已经走、代码还在手,可以毫无顾忌地说真话的机会,不多。
这是一个企业融合评估平台,2020年初立项,技术栈 Spring Boot 2.1 + MyBatis-Plus + Guava Cache + 华为云OBS,对接了Y云平台、问答卷、诊断系统三个外部系统。上线后跑了两年多,服务了上百家企业。
我离开之后,把代码从头到尾仔细地翻了一遍,不是为了交接,是为了复盘。
看到了5个让我眼前一亮的设计——"这个写法不错,有想法,值得借鉴"。
也看到了5个让我直摇头的坑——"这个怎么这么写?迟早要出事的!"。
今天全写出来。不点名,不甩锅,纯粹技术讨论。
项目速览
| 维度 | 信息 |
|---|---|
| 业务 | X省企业注册→诊断评估→遴选推荐 |
| 技术栈 | Spring Boot 2.1.0 + Java 8 + MyBatis-Plus 3.3.0 |
| 缓存 | Guava Cache(本地缓存) |
| 存储 | 华为云 OBS 对象存储 |
| 外部系统 | Y云平台(统一认证)、问答卷(答题)、诊断系统(评估) |
| 部署 | WAR包 + 外置Tomcat |
| 角色 | 企业管理员 / 职能部门人员 / 后台管理员 |
| 代码量 | 4个业务模块,约120个Java文件 |
5个亮点:这些地方有想法
亮点1:三个注解 + 一个切面,搞定三角色权限
项目没有用 Spring Security 或 Shiro,相对比较重,而是自己实现了一套轻量级的注解式鉴权。
三个标记注解,什么都不干,只是"贴标签":
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCompany {} // 企业管理员入口
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthDept {} // 职能部门人员入口
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthBack {} // 后台管理员入口
然后在 AuthAspect 里,一个 @Around 切面统一处理:
/**
* 所有controller public方法的切点
*/
@Pointcut("execution(public * com.xiaomayi.jser.*.controller.*.*(..))")
public void authPointcut() {}
/**
* 切面
*/
@Around("authPointcut()")
public Object authPointcut(ProceedingJoinPoint pjp) {
// 读取方法上的注解
AuthCompany annoAuthCompany = currentMethod.getAnnotation(AuthCompany.class);
AuthDept annoAuthDept = currentMethod.getAnnotation(AuthDept.class);
AuthBack annoAuthBack = currentMethod.getAnnotation(AuthBack.class);
// 按注解类型做不同的鉴权
if (annoAuthCompany != null) { /* 校验企业登录态 */ }
if (annoAuthDept != null) { /* 校验职能人员登录态 */ }
if (annoAuthBack != null) { /* 校验后台管理员登录态 */ }
return pjp.proceed();
}
亮点在哪?
- 零侵入:Controller 方法上加个注解就行,不用写任何鉴权代码
- 集中管控:所有鉴权逻辑在一个切面里,改一处生效全局
- 轻量级: 不用引用第三方框架,基于已有的技术,学习成本低。
- 三角色隔离:
@AuthCompany、@AuthDept、@AuthBack对应三种 Cookie Token(COMPANY_TOKEN / DEPT_TOKEN / BACK_TOKEN),互不干扰
对比那些在每个方法里写 if (session.getAttribute("role") == null) 的项目,这个设计高了一个维度。
亮点2:Session 丢了能自动恢复
这个设计是真正让我眼前一亮的地方。
场景:用户正在页面操作呢,Session 突然丢了(服务器重启、负载均衡切换等情况)。一般的项目做法是——直接跳到登录页。
这个项目多了一层:用 Cookie 恢复 Session。
private void resetSessionInfo(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
// 企业管理员Token
if ("COMPANY_TOKEN".equalsIgnoreCase(cookie.getName())) {
String uuid = cookie.getValue();
// 从 Guava Cache缓存中用 Token 找回用户信息
JSONObject cacheData = (JSONObject) CACHES.getIfPresent(uuid);
if (cacheData == null) {
continue;
}
// 恢复到 Session
HttpSession session = request.getSession();
session.setAttribute(SESSION_TOKEN, cacheData.getString(SESSION_TOKEN));
session.setAttribute(SESSION_COMPANY_USER_INFO, cacheData.getJSONObject(SESSION_COMPANY_USER_INFO));
session.setAttribute(SESSION_COMPANY_TYPE, cacheData.getIntValue(SESSION_COMPANY_TYPE));
// ... 共恢复7个Session属性
}
// DEPT_TOKEN 和 BACK_TOKEN 同理
}
}
}
原理:双写 + 自动恢复
登录时:
Session 写用户信息 ← 主存储
Cookie 写 Token ← 凭证
Cache 写 Token→用户映射 ← 备份
Session丢失时:
① 检测 Session 为空
② 读 Cookie 中的 Token
③ 用 Token 从 Cache 查用户信息
④ 恢复到 Session
⑤ 用户全程无感知
这个设计解决了一个真实痛点:用户体验的敌人不是黑客,是Session超时。
当然,它也有局限——Guava Cache 是本地缓存,maximumSize=50,最多存50个用户。这个在后面的"5个坑"再讨论。
亮点3:动态Excel二级表头并集算法
项目要导出诊断报告Excel,但问答卷返回的数据结构是动态的——每个企业的诊断维度、字段不一样,表头不固定。
exportPlustekDiagnosis 方法(365行)实现了一套完整的动态表头算法:
第一步:数据清洗
→ 过滤掉诊断系统返回异常的数据(网络抖动、响应非200)
第二步:一级表头并集
→ 遍历所有企业数据,收集所有一级表头名称(去重)
→ Set<String> oneTitle = new HashSet<>();
第三步:二级表头并集
→ 对每个一级表头,收集其下所有二级表头(去重)
→ Map<String, Set<String>> titleMap
第四步:双行表头 + 虚拟列标识
→ 第一行:一级表头,用 CellRangeAddress 合并单元格
→ 第二行:二级表头
→ 虚拟列:"一级表头-二级表头" 作为唯一Key(用于数据匹配)
第五步:数据填充
→ 按虚拟列Key匹配数据,没有的填"-"
精妙之处:虚拟列标识
// virtualColumn 和 titleBuffer 严格同步
// virtualColumn 存储 "一级表头-二级表头"(唯一Key,用于数据匹配)
// titleBuffer 只存储二级表头名称(可以重复,用于Excel显示)
StringBuffer virtualColumn = new StringBuffer("企业名称,企业所属行业,...,");
for (Map.Entry<String, Set<String>> entry : titleMap.entrySet()) {
String key = entry.getKey(); // 一级表头
Set<String> value = entry.getValue(); // 二级表头集合
for (String twoTitle : value) {
titleBuffer.append(twoTitle).append(","); // 显示用
virtualColumn.append(key + "-" + twoTitle).append(","); // 数据匹配用
}
}
这个"虚拟列"的设计很巧妙——Excel显示的是二级表头名称(可以重复),但数据填充用的是"一级-二级"拼接的唯一Key,解决了"不同一级表头下可能有同名二级表头"的冲突问题。
亮点4:账户变更实时检测
一般的鉴权系统,登录成功后就信任Session直到过期。但如果管理员在后台修改了某个职能人员的账号(改了密码、改了辖区、甚至删了账号),这个职能人员还在页面上继续操作呢——数据安全性存在隐患。
这个项目在切面里加了实时检测:
// 每次请求都检查账户是否被修改
SysAccount deptUserInfo = SessionCacheUtils.getDeptUserInfo();
if (isModifiedSysAccount(deptUserInfo)) {
//让重新登录
return new Result<>().fail(ResultEnum.NO_LOGIN);
}
private boolean isModifiedSysAccount(SysAccount userInfo) {
// 查数据库,比对7个关键字段
List<SysAccount> dbAccount = sysAccountMapper.queryByCond(params);
return isModified(userInfo, dbAccount.get(0));
}
//比对7个关键字段
private boolean isModified(SysAccount session, SysAccount db) {
if (!compareString(session.getAccount(), db.getAccount()))
return true;
if (!compareString(session.getPassword(), db.getPassword()))
return true;
if (!compareString(session.getAccountname(), db.getAccountname()))
return true;
if (!compareInteger(session.getAccountflag(), db.getAccountflag()))
return true;
if (!compareString(session.getTelphone(), db.getTelphone()))
return true;
if (!compareInteger(session.getCitycode(), db.getCitycode()))
return true;
if (!compareInteger(session.getAreacode(), db.getAreacode()))
return true;
return false;
}
每次请求都查DB比对——账号、密码、账户名、账户类型、手机号、城市编码、区域编码,7个字段任何一个变了,立即踢出,重新登录。
这个设计的安全意识值得肯定。当然,性能上每次请求都查DB是个问题(后面在坑里说),但思路是对的。
亮点5:雪花算法ID生成 + Spring配置化集成
项目没有用数据库自增ID,而是引入了雪花算法生成分布式唯一ID:
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards.");
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
并且做了Spring配置化集成:
# application.yml
pubframe:
id:
workId: 10
centerId: 20
@Configuration
public class AutoConfiguration {
@Bean
@ConfigurationProperties(prefix = "pubframe.id")
public IdGenProperties idGenProperties() {
return new IdGenProperties();
}
@Bean
public SnowflakeIdGenerator snowflakeIdGenerator(IdGenProperties properties) {
return new SnowflakeIdGenerator(properties.getWorkId(), properties.getCenterId());
}
}
/**
* Twitter_Snowflake<br>
* SnowFlake的结构如下(每部分用-分开):<br>
* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>
* 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0<br>
* 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截) 得到的值,这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。
* 41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>
* 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId<br>
* 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号<br>
* 加起来刚好64位,为一个Long型。<br>
* SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
*
* @Author xiaomayi
*/
public class SnowflakeIdGenerator {}
亮点在哪?
- 为分布式预留了能力:workId 和 centerId 通过配置文件指定,不同节点配不同值
- Spring Bean 化:其他模块直接
@Autowired注入就能用 - 注释清楚:64位结构、每一位的含义、使用年限,全在类注释里写明白了
5个坑:这些地方迟早要出事
坑1:Controller 写了1600行
EnterpriseRegistController类——1600行,一个文件干了6件事:
190 行校验逻辑写在Controller里:
if (enterpriseBaseinfo.getEstablishTime() == null) {
sb.append("企业成立时间必填;");
}
if (StringUtils.isEmpty(enterpriseBaseinfo.getTotalAssets())) {
sb.append("企业总资产(万元)必填;");
}
// ... 还有18个if
137行样式代码——同样的4行边框设置重复了9次:
RegionUtil.setBorderBottom(BORDER_THIN, cellRangeAddress0, sheet, wb);
RegionUtil.setBorderTop(BORDER_THIN, cellRangeAddress0, sheet, wb);
RegionUtil.setBorderLeft(BORDER_THIN, cellRangeAddress0, sheet, wb);
RegionUtil.setBorderRight(BORDER_THIN, cellRangeAddress0, sheet, wb);
// ↑ 重复9次,只改变量名
一个for循环就能解决的事。
参考上一篇深入分析和优化解决方案
坑2:Guava Cache 最大50条
项目里有两个Guava Cache:
// AuthAspect.java - 存登录Token
private static final Cache<String, Object> CACHES = CacheBuilder.newBuilder()
.maximumSize(50) // 最多50个用户
.expireAfterWrite(30, TimeUnit.MINUTES) // 30分钟过期
.build();
// SessionCacheUtils.java - 存匿名Token
private static final Cache<String, Object> CACHES = CacheBuilder.newBuilder()
.maximumSize(50) // 也是50
.expireAfterWrite(60, TimeUnit.MINUTES)
.build();
maximumSize=50——也就是说同时在线用户超过50个,第51个登录就会把最早的那个挤出去。
被挤出去的人会怎样?Session丢了 → 触发 resetSessionInfo → 从Cache恢复 → Cache里也没有了(被挤掉了)→ 跳到登录页。
这个项目是政府类平台,同时在线用户可能不多,所以暂时没出事。但如果用户量上来,这就是一个定时炸弹。
解决办法有很多,比如写在配置文件中,支持动态刷新等。大家还有其它方法吗?可以在评论区留言讨论。
坑3:Guava Cache 是本地缓存
服务器A的 Cache里存了用户X的Token → 用户X的请求打到服务器B → 服务器B的Cache里没有 → 恢复失败 → 跳登录页
在分布式环境下,本地缓存不共享,恢复机制会失效。
坑4:异常处理写了三遍一模一样的 catch
} catch (SystemException e) {
log.error(e.getMessage(), e); // ← 完全相同
if (e.getResultEnum() != null) { // ← 完全相同
return this.returnObject(currentMethod, new Result<>().fail(e.getResultEnum())); // ← 完全相同
} else {
return this.returnObject(currentMethod, new Result<>().fail(e.getMessage())); // ← 完全相同
}
} catch (RapplyException e) {
log.error(e.getMessage(), e); // ← 完全相同
if (e.getResultEnum() != null) { // ← 完全相同
return this.returnObject(currentMethod, new Result<>().fail(e.getResultEnum()));
} else { // ← 完全相同
return this.returnObject(currentMethod, new Result<>().fail(e.getMessage()));
}
} catch (ElecException e) {
log.error(e.getMessage(), e); // ← 完全相同
if (e.getResultEnum() != null) { // ← 完全相同
return this.returnObject(currentMethod, new Result<>().fail(e.getResultEnum()));
} else { // ← 完全相同
return this.returnObject(currentMethod, new Result<>().fail(e.getMessage()));
}
}
三个自定义异常(SystemException、RapplyException、ElecException),catch逻辑一字不差地重复了三遍。
一个公共接口就能解决:
// 让三个异常都实现这个接口
public interface Resultable {
ResultEnum getResultEnum();
}
// 然后只需要一个 catch
} catch (Resultable e) {
log.error(e.getMessage(), e);
return this.returnObject(currentMethod,
e.getResultEnum() != null
? new Result<>().fail(e.getResultEnum())
: new Result<>().fail(e.getMessage()));
}
21行 → 7行。而且以后加新异常,不用改切面。
或者简写:
catch (SystemException | RapplyException | ElecException e)
坑5:硬编码 paperid==0/1/2/3
if (paperid == 0) {
return exportPlustekDiagnosis(response); // 数字化生产诊断
} else if (paperid == 1) {
return exportInternetPlatform(response, data); // 工业平台建设
} else if (paperid == 2) {
return exportExampleFactory(response, data); // 工业互联网标杆工厂
} else if (paperid == 3) {
return exportEnterpriseCloud(response, data); // 企业上云
}
产品经理说:"要加第5个评估模型。" ——改Controller。 "再加一个。" ——继续改Controller。 "还有第3个模型的导出逻辑要改。" ——在1600行的文件里找到那个方法,小心翼翼地改,默然祈祷不要影响别的。
这就是违反开闭原则的经典案例。 每加一种导出,就要修改核心代码。
正确的做法是策略模式+工厂模式——新增模型只需加一个实现类,Controller一行不改。
坑6:账户变更检测,每次请求都查DB
在上面的 “亮点4” 里说了"账户变更实时检测"——思路很好,但实现方式是:
// 每次请求都执行
private boolean isModifiedSysAccount(SysAccount userInfo) {
Map qsaParams = new HashMap();
qsaParams.put("id", userInfo.getId());
List<SysAccount> sysAccounts = sysAccountMapper.queryByCond(qsaParams);
// ... 比对7个字段
}
每一个需要鉴权的请求,都会触发一次数据库查询。
假设系统每秒100个请求,就是每秒100次额外的DB查询——这些查询纯粹是为了安全检查,不是业务需要。
更好的做法是 “管理员修改时主动失效(事件驱动)”:
【正常请求链路】
用户请求 → 切面拦截
│
├─ ① Session 有用户信息?→ 有
├─ ② Cache 里有这个用户的"有效标记"?
│ ├─ 有 → 放行(不查DB!)
│ └─ 没有 → 踢出,返回 NO_LOGIN
│
└─ 每次请求只查 Cache,不查DB → 零额外DB开销
【管理员修改账户链路】
管理员点击"修改账户" → 后端执行
│
├─ ① 更新DB:UPDATE sys_account SET ...
├─ ② 清除该用户在 Cache 中的"有效标记"
│ CACHES.invalidate(userId + "_VALID")
│
└─ 下次该用户请求 → Cache里没有标记 → 踢出
安全意识满分,性能意识差了一截。
老炮点评
18年老兵的真心话:
"注解式鉴权"是这个项目最有价值的设计。 三个注解+一个切面,比引入Spring Security轻量十倍,效果却不差。小项目完全可以借鉴。
"Session自动恢复"的思路比实现更重要。 实现有局限(Guava Cache 50条),但思路是对的——认证状态不要只存在一个地方。
"动态表头并集算法"是真正的技术含量。 365行代码,但核心思想就一句话——"集合的并集运算"。想通了这一点,代码可以精简到100行。
"1600行Controller"是所有小项目的通病。 不是开发者水平不行,是项目初期没人告诉你该拆分。等到想拆的时候,已经拆不动了。
"每次请求查DB做安全检查"是典型的'思路对了,实现差了'。 安全意识值得肯定,但性能代价太大。好的安全机制应该是"事件驱动"的,不是"轮询驱动"的。
写在最后:
这个项目不完美,但它真实。
它没有用Spring Security,但自己实现的注解鉴权够用且优雅。它没有用Redis,但Guava Cache + Cookie的Session恢复方案,在当时的场景下是合理的。它有很多坑,但它在线上跑了两年,服务了几百家企业,没出过事故。
好的项目不是没有Bug的项目,是在有限资源下做出了最优选择的项目。
这5个亮点和6个坑,不是批评,是一次坦诚的复盘。
下篇预告:《自定义注解 + AOP 实现轻量级角色鉴权:从设计到落地》
本文我列了5个项目亮点,其中有一个是我认为这个项目里设计得最漂亮的——注解 + AOP 实现的三角色权限控制。下期我会把这个框架的完整代码拆出来,讲清楚它怎么做到 “一个注解搞定鉴权”,以及为什么它比传统的if-else强。
如果你正在维护老项目,或者想给自己项目加一套轻量级鉴权,下期这篇值得。
我是老炮,18年Java老兵。关注我,少走弯路。