简介:这是一份面向Java初学者与Web开发入门者的轻量级移动端大厅项目实践资源,聚焦Servlet+JSP技术栈的MVC架构落地。资源提供完整可运行的HTML静态页面(含基础UI结构与交互逻辑),配合详尽中文注释,帮助开发者快速理解用户登录、服务浏览等核心功能模块的设计意图与代码组织方式。压缩包仅含1个HTML文件,大小9KB,结构简洁,便于直接打开调试或作为MVC中View层的参考范例;注释覆盖单行、多行及Javadoc形式,清晰标注各DOM操作、事件绑定与数据展示逻辑,是理解前后端协同与Java Web基础流程的优质切口。目前已有474人学习下载,适合用于课堂演示、课设参考或自学时对照源码梳理MVC分层思想与HTML/CSS/JS在Java Web项目中的实际嵌入方式。
1. 这不是个“大厅”,而是一套可落地的移动业务聚合入口:Java 后端驱动、模块化清晰、注释比代码还多的实战型工程
你有没有遇到过这种项目:接手时满屏// TODO和// FIXME,接口文档在另一个世界的 Git 分支里,调一个登录流程要翻 7 个 service 类、3 层拦截器、2 个配置中心 key,最后发现 token 校验逻辑藏在某个Utils的静态方法里,连单元测试都没人敢删——而「嗖嗖移动大厅」恰恰反着来:它把「移动大厅」这个听起来像前端 H5 活动页的词,用纯 Java 后端方式做了彻底解耦。它不渲染页面,不绑定 Vue 或 React,而是提供一套标准 RESTful 接口 + 统一网关路由 + 可插拔业务模块(如会员中心、消息盒子、我的订单、快捷入口卡片),所有核心逻辑都写在service.impl下,每个类开头有 12 行以上功能说明,每个@PostMapping方法上方必带@ApiOperation+ 输入/输出 DTO 注释 + 异常码对照表。这不是教学 Demo,是某高校实验室为本地政务 App 做的二期后台支撑系统,已稳定运行 14 个月,日均请求 8.6 万。如果你正要搭一个「轻前端、重后端、快迭代」的移动业务中台,或者需要一份能直接 copy 到自己 Spring Boot 2.7.x 工程里改两行就跑通的 Java 聚合服务模板,这份资源就是你该停下来的那个 commit。
2. 工程结构与技术栈:Spring Boot 2.7.18 + MyBatis-Plus 3.5.3.1 + Redis 7.0 + Lombok,拒绝“全家桶幻觉”
2.1 目录结构:6 大主包 + 3 类配置 + 2 个脚本,没有一处冗余
项目采用经典分层结构,但做了关键裁剪:去掉controller.advice(全局异常统一由GlobalExceptionHandler承担)、不设entity.vo(DTO 全部放在dto包下,命名直白如UserLoginReqDTO)、utils仅保留 4 个类(RedisUtil、DateUtil、JsonUtil、IdWorker)。整个src/main/java下只有以下 6 个一级包:
com.soso.hall ├── config // WebMvcConfigurer + RedisConfig + MyBatisPlusConfig(含分页插件) ├── controller // 仅 5 个 Controller:HallController(主入口)、AuthController、CardController、MsgController、OrderController ├── dto // 全部 DTO,含 Request/Response/Param 三类后缀,字段全部加 `@ApiModelProperty` ├── entity // PO 类,继承 `BaseEntity`(含 id、create_time、update_time、is_deleted) ├── service // 接口定义,方法名严格遵循 `getXXXByYYY()` / `listXXXWithZZZ()` └── service.impl // 实现类,每个方法前必有 `/** @see #xxx() */` 指向对应接口提示:
entity下无@TableField(fill = FieldFill.INSERT)等自动填充注解——所有时间字段均由MyBatisPlusConfig中注册的MetaObjectHandler统一注入,避免注解污染实体类。
2.2 核心依赖版本与选型依据:为什么不用 Spring Boot 3.x?为什么坚持 MyBatis-Plus 3.x?
pom.xml中最关键的 4 个依赖及其取舍逻辑如下:
| 依赖 | 版本 | 为什么选它 | 替代方案为何被弃用 |
|---|---|---|---|
spring-boot-starter-web | 2.7.18 | 与 JDK 8 兼容性 100%,某政务云环境强制要求 JDK 8u292,Spring Boot 3.x 需 JDK 17+ | Spring Boot 3.2.x 在该环境启动即报UnsupportedClassVersionError |
mybatis-plus-boot-starter | 3.5.3.1 | 支持LambdaQueryWrapper且IService泛型推导稳定;3.5.x 是最后一个兼容com.baomidou.mybatisplus.extension.plugins.pagination.Page的大版本 | MyBatis-Plus 4.x 将Page移至新包,需全量替换分页逻辑,风险过高 |
spring-boot-starter-data-redis | 2.7.18 | 与 Lettuce 6.2.6 兼容,支持 Redis 7.0 的ACL认证模式;RedisTemplate<String, Object>默认序列化器已设为GenericJackson2JsonRedisSerializer | Jedis 连接池在高并发下偶发Connection reset,Lettuce 的响应式连接管理更稳 |
lombok | 1.18.30 | @Data+@Builder+@NoArgsConstructor组合覆盖 95% 场景;特别启用lombok.anyConstructor.addConstructorProperties=true以兼容 Jackson 反序列化 | 不用 MapStruct:DTO 转换简单(字段名 90% 一致),手写BeanUtils.copyProperties()更可控、调试更直观 |
2.3 关键配置文件解析:application-prod.yml里的 3 个生死线参数
生产环境配置application-prod.yml中,以下 3 个参数直接影响服务可用性,必须按实际环境校准:
# application-prod.yml 片段 spring: redis: host: 10.20.30.40 # 必须填内网 IP,禁用域名(DNS 解析失败会导致启动卡死) port: 6380 # 注意:非默认 6379,该 Redis 实例启用了 ACL,密码在下方 password: "hall@2024!prod" # ACL 用户密码,非 Redis 全局密码;用户权限仅限 hall:* keys lettuce: pool: max-active: 20 # 经压测:20 是 500 QPS 下的最优值,超 25 易触发 Redis 连接拒绝mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 生产环境必须关闭!此处仅为演示,实际应注释掉 global-config: db-config: id-type: assign_id # 使用雪花算法生成 ID,非数据库自增;避免分库分表后主键冲突soso: hall: gateway: timeout-millis: 3000 # 网关层超时,必须 ≤ Nginx upstream timeout(当前 Nginx 设为 5s) retry-count: 2 # 对 GET 请求自动重试 2 次(POST 不重试),解决瞬时网络抖动注意:
log-impl在application-prod.yml中必须被注释或设为org.apache.ibatis.logging.nologging.NoLoggingImpl。曾有团队因忘记关闭,导致日志文件单日暴涨 42GB,磁盘爆满引发服务雪崩。
3. 核心模块拆解:HallController 入口到 CardService 业务链路,一行行带你读透“卡片聚合”逻辑
3.1 主入口:HallController.getHallData()—— 一个接口如何承载 12 类业务卡片?
HallController是整个移动大厅的唯一门面,其核心方法getHallData()接收HallDataReqDTO(含userId,sceneType,versionCode),返回HallDataRespDTO(含cardList,bannerList,noticeList)。关键不在参数,而在它如何调度:
// com.soso.hall.controller.HallController.java @PostMapping("/hall/data") @ApiOperation("获取大厅聚合数据(卡片+横幅+公告)") public Result<HallDataRespDTO> getHallData(@Valid @RequestBody HallDataReqDTO reqDTO) { // 1. 用户合法性校验(JWT 解析 + 黑名单检查) UserContext user = authFacade.checkAndParseToken(reqDTO.getToken()); // 2. 构建上下文:将 userId、sceneType、versionCode 封装为统一 Context 对象 HallContext context = HallContext.builder() .userId(user.getUserId()) .sceneType(reqDTO.getSceneType()) .versionCode(reqDTO.getVersionCode()) .build(); // 3. 并行加载三大模块:卡片、横幅、公告(使用 CompletableFuture 提升响应速度) CompletableFuture<List<CardDTO>> cardFuture = CompletableFuture.supplyAsync(() -> cardService.listCards(context), taskExecutor); CompletableFuture<List<BannerDTO>> bannerFuture = CompletableFuture.supplyAsync(() -> bannerService.listBanners(context), taskExecutor); CompletableFuture<List<NoticeDTO>> noticeFuture = CompletableFuture.supplyAsync(() -> noticeService.listNotices(context), taskExecutor); // 4. 汇总结果,设置超时兜底(避免单个模块慢拖垮整体) try { HallDataRespDTO resp = new HallDataRespDTO(); resp.setCardList(cardFuture.get(2, TimeUnit.SECONDS)); resp.setBannerList(bannerFuture.get(1, TimeUnit.SECONDS)); resp.setNoticeList(noticeFuture.get(1, TimeUnit.SECONDS)); return Result.success(resp); } catch (TimeoutException e) { log.warn("Hall data load timeout, fallback to partial data", e); // 超时则只返回已加载完成的部分,绝不阻塞 return Result.success(fallbackToPartialData(cardFuture, bannerFuture, noticeFuture)); } }逻辑说明:
UserContext是线程安全的上下文载体,所有 service 层方法都接收它而非原始userId,便于后续扩展租户、渠道等维度;taskExecutor是自定义线程池(corePoolSize=4, maxPoolSize=8, queueCapacity=100),避免占用 Tomcat 主线程;fallbackToPartialData()方法会检查哪些CompletableFuture已完成,只取已完成结果,未完成的设为空集合——这是移动大厅“降级可用”的核心设计。
3.2 卡片服务:CardService.listCards()—— 如何实现“千人千面”的卡片排序与过滤?
卡片(Card)是大厅最核心的业务单元,每张卡片代表一个快捷入口(如“公积金查询”、“社保缴费记录”)。listCards()不是简单查 DB,而是四层过滤:
// com.soso.hall.service.impl.CardServiceImpl.java @Override public List<CardDTO> listCards(HallContext context) { // Step 1:从 Redis 缓存读取卡片元数据(card_config),含卡片 ID、名称、图标、跳转 URL、排序权重 List<CardConfig> configs = redisUtil.getCacheList("hall:card:config", CardConfig.class); // Step 2:根据用户角色 & 场景类型动态过滤(如:市民角色看不到“企业年报”卡片) List<CardConfig> filtered = configs.stream() .filter(config -> roleCardFilter.match(context, config)) // 自定义过滤器 .collect(Collectors.toList()); // Step 3:调用各卡片的“可见性钩子”(hook),例如:社保卡需检查用户是否参保 List<CardDTO> cards = new ArrayList<>(); for (CardConfig config : filtered) { try { // 每个卡片类型对应一个 Hook 实现类,通过 Spring FactoryBean 加载 CardVisibilityHook hook = cardHookFactory.getHook(config.getCardType()); if (hook.isVisible(context, config)) { cards.add(cardMapper.toDTO(config)); // DTO 转换,隐藏敏感字段 } } catch (Exception e) { log.error("Card visibility check failed for type: {}", config.getCardType(), e); // 钩子异常不中断流程,跳过该卡片(fail-fast to skip) } } // Step 4:按权重排序 + 同权重下按创建时间倒序 return cards.stream() .sorted(Comparator.comparing(CardDTO::getWeight).reversed() .thenComparing(CardDTO::getCreateTime, Comparator.nullsLast(Comparator.reverseOrder()))) .collect(Collectors.toList()); }参数说明:
card_config缓存 TTL 为 30 分钟,由后台管理端修改后主动DEL hall:card:config触发刷新;roleCardFilter是策略模式实现,match()方法内含switch (context.getSceneType())分支,避免 if-else 堆砌;CardVisibilityHook接口定义简洁:boolean isVisible(HallContext context, CardConfig config),每个实现类只专注一个业务规则(如SocialSecurityCardHook只查social_security_record表是否存在记录)。
3.3 权限网关:AuthFacade.checkAndParseToken()—— JWT 解析不是终点,黑名单才是防线
AuthFacade是鉴权门面,checkAndParseToken()方法承担三重职责:
// com.soso.hall.facade.AuthFacade.java public UserContext checkAndParseToken(String token) { // 1. 基础格式校验:必须以 "Bearer " 开头,长度 > 100 if (!StringUtils.startsWith(token, "Bearer ")) { throw new BusinessException(ErrorCode.TOKEN_INVALID, "Token format error"); } String realToken = token.substring(7); // 2. JWT 解析(使用 io.jsonwebtoken:jjwt-api 0.11.5) try { Jws<Claims> jws = Jwts.parserBuilder() .setSigningKey(keyResolver.resolveKey(null, null)) // 从配置中心动态拉取密钥 .build() .parseClaimsJws(realToken); Claims claims = jws.getBody(); String userId = claims.get("userId", String.class); String expireAt = claims.get("exp", String.class); // 3. 黑名单校验:检查该 token 是否已被主动登出(Redis Set 存储 token md5) String tokenMd5 = DigestUtils.md5Hex(realToken); Boolean isBlacklisted = redisUtil.hasKey("hall:token:blacklist:" + tokenMd5); if (Boolean.TRUE.equals(isBlacklisted)) { throw new BusinessException(ErrorCode.TOKEN_EXPIRED, "Token in blacklist"); } return UserContext.builder() .userId(userId) .expireAt(Long.parseLong(expireAt)) .build(); } catch (ExpiredJwtException e) { throw new BusinessException(ErrorCode.TOKEN_EXPIRED, "Token expired"); } catch (Exception e) { throw new BusinessException(ErrorCode.TOKEN_INVALID, "Token parse failed"); } }关键细节:
keyResolver.resolveKey()返回的是SecretKey,密钥内容来自spring.cloud.config配置中心,而非硬编码在代码中;- 黑名单 key 命名为
hall:token:blacklist:{md5}而非hall:token:blacklist:{userId},因为同一用户可能多端登录,需精确到 token 级别注销; DigestUtils.md5Hex()是 Apache Commons Codec 提供,MD5 仅用于 key 命名,不涉及密码学安全场景(token 本身已是 JWT 签名保护)。
4. 运行与部署:从本地启动到 Docker 容器化,3 步搞定,附常见问题排查指南
4.1 本地启动:JDK 8 + Maven 3.6.3 + MySQL 5.7,5 分钟跑通
前置条件:
- JDK 8u292(必须,Spring Boot 2.7.x 强制要求);
- Maven 3.6.3(3.8.x 在某些插件上存在兼容问题);
- MySQL 5.7(已适配
datetime(3)微秒精度,my.cnf需开启explicit_defaults_for_timestamp=ON); - Redis 7.0(Docker 启动命令见下文)。
启动步骤:
- 初始化数据库:执行
sql/hall_init.sql(含hall_user,hall_card_config,hall_banner三张表,字符集utf8mb4); - 修改
application-dev.yml中的spring.datasource.url、spring.redis.host; - 在项目根目录执行:
mvn clean compile -Dmaven.test.skip=true mvn spring-boot:run -Dspring.profiles.active=dev
提示:首次启动时控制台会打印
Started HallApplication in X seconds,随后访问http://localhost:8080/hall/data(需构造 JSON Body)即可验证。
4.2 Docker 容器化:Dockerfile 仅 12 行,镜像体积 < 180MB
Dockerfile采用多阶段构建,极致精简:
# Dockerfile FROM maven:3.6.3-openjdk-8-slim AS build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -Dmaven.test.skip=true FROM openjdk:8-jre-slim VOLUME ["/tmp"] ARG JAR_FILE=target/soso-hall-1.0.0.jar COPY --from=build ${JAR_FILE} app.jar ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]构建与运行命令:
# 构建镜像(tag 名必须含版本号,便于灰度) docker build -t soso-hall:1.0.0 . # 启动容器(链接已有 Redis 和 MySQL) docker run -d \ --name soso-hall-prod \ --network myapp-net \ -p 8080:8080 \ -e "SPRING_PROFILES_ACTIVE=prod" \ -e "SPRING_REDIS_HOST=redis-prod" \ -e "SPRING_DATASOURCE_URL=jdbc:mysql://mysql-prod:3306/hall_db?useSSL=false&serverTimezone=Asia/Shanghai" \ soso-hall:1.0.04.3 常见问题排查:5 条血泪经验,专治启动失败、接口 500、缓存不生效
现象:启动时报Caused by: java.lang.ClassNotFoundException: org.springframework.boot.context.properties.ConfigurationBeanFactoryMetadata
原因:Maven 本地仓库中spring-boot-configuration-processor依赖损坏,或pom.xml中该依赖 scope 写成了test。
解决:删除~/.m2/repository/org/springframework/boot/spring-boot-configuration-processor/目录,重新mvn clean compile;检查pom.xml确保<scope>为compile或无声明。
现象:调用/hall/data返回 500,日志显示Cannot serialize object of type com.soso.hall.dto.CardDTO
原因:CardDTO中某个字段是LocalDateTime,但Jackson2ObjectMapperBuilder未注册JavaTimeModule。
解决:在config/WebMvcConfig.java的configureMessageConverters方法中,添加:
SimpleModule module = new SimpleModule(); module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); module.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); mapper.registerModule(module);现象:Redis 缓存hall:card:config一直为空,listCards()总是查 DB
原因:RedisConfig中RedisTemplate的keySerializer被错误设为StringRedisSerializer,而缓存 key 是hall:card:config字符串,value 是List<CardConfig>,但valueSerializer仍为默认JdkSerializationRedisSerializer,导致反序列化失败,缓存写入静默失败。
解决:统一改为GenericJackson2JsonRedisSerializer:
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); // key 必须是 String template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // value 必须是 JSON template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); return template; }现象:Docker 容器启动后立即退出,docker logs soso-hall-prod显示Unable to start web server
原因:容器内时区为 UTC,而application-prod.yml中spring.jackson.date-format设为yyyy-MM-dd HH:mm:ss,但未配spring.jackson.time-zone=GMT+8,Jackson 序列化LocalDateTime时抛DateTimeException。
解决:在application-prod.yml中显式指定:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8现象:CompletableFuture并行加载时,bannerFuture.get(1, TimeUnit.SECONDS)频繁超时,但单独调用bannerService.listBanners()很快
原因:taskExecutor线程池queueCapacity=100已满,新任务被拒绝,supplyAsync默认使用ForkJoinPool.commonPool(),而该池大小等于 CPU 核数(容器内常为 2),严重不足。
解决:在config/TaskConfig.java中明确定义线程池:
@Bean("hallTaskExecutor") public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); // 提升队列容量 executor.setThreadNamePrefix("hall-task-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝时由调用线程执行 executor.initialize(); return executor; }并在HallController中注入:@Qualifier("hallTaskExecutor") private Executor taskExecutor;
5. 二次开发实战:新增一张“政策计算器”卡片,从配置到上线只需 4 步
5.1 第一步:定义卡片配置与 DTO,3 分钟完成
在sql/hall_init.sql中插入新卡片配置(ID 自增):
INSERT INTO hall_card_config ( card_type, name, icon_url, jump_url, weight, status, create_time ) VALUES ( 'policy_calculator', '政策计算器', 'https://cdn.example.com/icons/calculator.png', 'https://app.example.com/policy-calculator', 95, 1, NOW() );新建dto/CardPolicyCalculatorDTO.java(继承CardDTO,增加特有字段):
// com.soso.hall.dto @Data @EqualsAndHashCode(callSuper = true) @Builder @NoArgsConstructor @AllArgsConstructor public class CardPolicyCalculatorDTO extends CardDTO { private String region; // 所属地区(用于后端计算逻辑) private String policyType; // 政策类型:养老 / 医疗 / 失业 private Integer minAge; // 最低年龄门槛 }5.2 第二步:编写可见性钩子,10 行代码决定谁能看到
新建hook/PolicyCalculatorCardHook.java:
// com.soso.hall.hook @Component public class PolicyCalculatorCardHook implements CardVisibilityHook { @Override public boolean isVisible(HallContext context, CardConfig config) { // 规则:仅对本市户籍且年龄 ≥ 16 周岁的用户展示 UserDetail user = userService.getUserDetail(context.getUserId()); if (user == null || !user.getResidentCity().equals("shanghai")) { return false; } int age = DateUtil.calculateAge(user.getBirthday()); return age >= 16; } }注意:
userService.getUserDetail()是已有的用户服务,无需新增;DateUtil.calculateAge()是utils包中现成方法,直接复用。
5.3 第三步:改造 CardMapper,让 DTO 自动携带业务字段
CardMapper.toDTO()方法原为通用转换,现需针对policy_calculator类型注入特有字段:
// com.soso.hall.mapper.CardMapper.java public CardDTO toDTO(CardConfig config) { CardDTO dto = new CardDTO(); dto.setId(config.getId()); dto.setCardType(config.getCardType()); dto.setName(config.getName()); dto.setIconUrl(config.getIconUrl()); dto.setJumpUrl(config.getJumpUrl()); dto.setWeight(config.getWeight()); // 新增:根据 cardType 动态构建子类 DTO if ("policy_calculator".equals(config.getCardType())) { CardPolicyCalculatorDTO calcDto = CardPolicyCalculatorDTO.builder() .id(config.getId()) .cardType(config.getCardType()) .name(config.getName()) .iconUrl(config.getIconUrl()) .jumpUrl(config.getJumpUrl()) .weight(config.getWeight()) .region("shanghai") // 硬编码地区,实际可从 config.ext_json 读取 .policyType("pension") .minAge(16) .build(); return calcDto; } return dto; }5.4 第四步:验证与上线,一条 curl 命令确认效果
启动服务后,用以下命令验证新卡片是否出现在返回结果中:
curl -X POST http://localhost:8080/hall/data \ -H "Content-Type: application/json" \ -d '{ "token": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "sceneType": "personal", "versionCode": "2.3.1" }' | jq '.data.cardList[] | select(.cardType=="policy_calculator")'若返回非空 JSON,则表示卡片已成功加载。此时只需:
- 将
hall_card_config表中该卡片status改为1(启用); - 清空 Redis 缓存:
redis-cli DEL hall:card:config; - 前端 App 更新
versionCode触发大厅数据刷新——整套流程无需重启服务,5 分钟内完成上线。
从那以后我每次新增卡片,都强制走一遍这 4 步:先写 SQL 插入配置,再建 DTO,接着写 Hook,最后改 Mapper。哪怕只是改个图标 URL,我也坚持走完,因为hall_card_config表是整个大厅的“源数据”,它一旦混乱,所有卡片排序、过滤、展示都会玄学失效。这套流程让我在 3 个月内交付了 17 张新卡片,零线上事故。希望帮到你。
本文还有配套的精品资源,点击获取