一个2022年Java项目的完整复盘:5个值得借鉴的设计 + 6个必须避开的坑

24 阅读14分钟

老炮踩坑录 · 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();
}

亮点在哪?

  1. 零侵入:Controller 方法上加个注解就行,不用写任何鉴权代码
  2. 集中管控:所有鉴权逻辑在一个切面里,改一处生效全局
  3. 轻量级: 不用引用第三方框架,基于已有的技术,学习成本低。
  4. 三角色隔离:@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 {}

亮点在哪?

  1. 为分布式预留了能力:workId 和 centerId 通过配置文件指定,不同节点配不同值
  2. Spring Bean 化:其他模块直接 @Autowired 注入就能用
  3. 注释清楚:64位结构、每一位的含义、使用年限,全在类注释里写明白了

5个坑:这些地方迟早要出事

坑1:Controller 写了1600行

EnterpriseRegistController类——1600行,一个文件干了6件事:

image-20260908175301292.png 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年老兵的真心话:

  1. "注解式鉴权"是这个项目最有价值的设计。 三个注解+一个切面,比引入Spring Security轻量十倍,效果却不差。小项目完全可以借鉴。

  2. "Session自动恢复"的思路比实现更重要。 实现有局限(Guava Cache 50条),但思路是对的——认证状态不要只存在一个地方。

  3. "动态表头并集算法"是真正的技术含量。 365行代码,但核心思想就一句话——"集合的并集运算"。想通了这一点,代码可以精简到100行。

  4. "1600行Controller"是所有小项目的通病。 不是开发者水平不行,是项目初期没人告诉你该拆分。等到想拆的时候,已经拆不动了。

  5. "每次请求查DB做安全检查"是典型的'思路对了,实现差了'。 安全意识值得肯定,但性能代价太大。好的安全机制应该是"事件驱动"的,不是"轮询驱动"的。


写在最后:

这个项目不完美,但它真实。

它没有用Spring Security,但自己实现的注解鉴权够用且优雅。它没有用Redis,但Guava Cache + Cookie的Session恢复方案,在当时的场景下是合理的。它有很多坑,但它在线上跑了两年,服务了几百家企业,没出过事故。


好的项目不是没有Bug的项目,是在有限资源下做出了最优选择的项目。

这5个亮点和6个坑,不是批评,是一次坦诚的复盘。

下篇预告:《自定义注解 + AOP 实现轻量级角色鉴权:从设计到落地》

​ 本文我列了5个项目亮点,其中有一个是我认为这个项目里设计得最漂亮的——注解 + AOP 实现的三角色权限控制。下期我会把这个框架的完整代码拆出来,讲清楚它怎么做到 “一个注解搞定鉴权”,以及为什么它比传统的if-else强。

如果你正在维护老项目,或者想给自己项目加一套轻量级鉴权,下期这篇值得。

我是老炮,18年Java老兵。关注我,少走弯路。