青训营级工程实践模板:Spring Boot 3.2 + Vue 3 全链路落地指南

发布时间:2026/10/26 4:39:36
青训营级工程实践模板:Spring Boot 3.2 + Vue 3 全链路落地指南

简介:本资源是字节跳动青训营官方项目实战的完整代码工程包,面向IT初学者、转行求职者及希望夯实全栈开发能力的在校学生,聚焦真实工业级Web项目开发流程,系统训练编程实践、工程协作与问题解决能力。压缩包共65个文件,以45个JS/JSX文件(含React组件、API调用、状态管理逻辑)、7个CSS/SCSS样式文件、3个JSON配置(含Webpack、package及路由配置)为核心,辅以HTML入口、SVG/ICO图标、Git版本控制文件及MIT许可证等标准化工程要素,整体仅449KB,轻量但结构完整,涵盖前端构建、模块化开发、基础CI/CD配置和文档规范。目前已有386人学习下载,资源目录清晰呈现典型现代前端项目骨架——含src/components/router/redux/views分层结构、public静态资源、webpack.config.js构建配置及README.md说明,开箱即可运行、调试与二次开发,是理解字节系技术选型与工程实践的优质入门范例。

1. 这不是字节跳动内部系统,而是一套可落地的「青训营级」工程实践模板:覆盖从 CLI 工具链、微服务通信到前端状态同步的完整闭环,适合想摆脱玩具 Demo、真正写出能上线、能协作、能演进的中型项目的开发者

你可能在 GitHub 上见过几十个“字节跳动开源项目”,但它们大多聚焦单点技术(比如某个算法库或 UI 组件),缺乏真实业务场景下的工程粘合逻辑。而这个名为“字节跳动青训营项目实战”的压缩包,本质是一套被反复验证过的教学级生产骨架——它不模拟大厂后端高并发,也不堆砌前沿框架,而是用一套精简但完整的电商导购场景(商品搜索 + 推荐流 + 用户行为埋点 + 管理后台),把「一个功能从需求文档走到线上灰度」的每一步都固化成可运行、可调试、可替换的代码模块。我拿它给某高校实验室带过三期实训,学生反馈最集中的不是“学到了什么”,而是“终于知道为什么导师总说‘你这代码没法加日志’‘接口没版本号以后怎么兼容’”。它适合两类人:一是刚写完 Spring Boot 单体、卡在“不知道下一步该补什么工程能力”的中级开发者;二是带团队做内部工具平台、需要快速拉起一套有规范、有监控、有部署路径的脚手架的技术负责人。项目里没有黑匣子,所有配置项、启动参数、目录约定、CI 脚本都暴露在明处,改一行就能看到效果,而不是靠玄学重启。


2. 项目结构与技术栈解剖:基于 Spring Boot 3.2 + Vue 3 + Nginx 的三层分治架构,核心是「协议先行」与「边界显式化」

这个压缩包解压后共 4 个一级目录:backend(Java)、frontend(Vue)、infra(Docker/Nginx/CI)、docs(设计文档与接口契约)。它没用 Kubernetes,也没上 Service Mesh,但通过清晰的分层和显式契约,实现了比很多“云原生项目”更易理解的协作边界。下面逐层拆解其设计意图与真实代码组织逻辑。

2.1 backend:Spring Boot 3.2 多模块结构,每个 module 对应一个明确的业务域与部署单元

backend目录下不是传统单模块 Maven 结构,而是按 DDD 思路划分为:

  • common:共享实体、异常码、基础工具类(含Result<T>封装、ErrorCode枚举)
  • search-service:商品搜索主服务,暴露/api/v1/search,依赖 Elasticsearch 8.x
  • recommend-service:推荐流服务,暴露/api/v1/recommend,含协同过滤简易实现
  • gateway:Spring Cloud Gateway 实例,负责路由、鉴权(JWT)、限流(Sentinel 嵌入式)

关键点在于:所有 service 模块均不直接暴露 Controller,而是通过api子模块定义 OpenAPI 3.0 规范的接口契约。例如search-service/api/src/main/resources/openapi.yaml中定义:

/openapi.yaml paths: /v1/search: get: summary: 商品关键词搜索 parameters: - name: keyword in: query required: true schema: type: string - name: page in: query schema: type: integer default: 1 responses: '200': description: 成功 content: application/json: schema: $ref: '#/components/schemas/SearchResponse'

提示:项目未使用springdoc-openapi-ui自动生成 Swagger 页面,而是用openapi-generator-maven-plugin在编译期生成 Java Client 和 TypeScript 接口定义。这样做的好处是前后端联调时,TypeScript 的SearchApi.search()方法签名与后端完全一致,类型错误在编译期就暴露,而非运行时报Cannot read property 'items' of undefined。

2.2 frontend:Vue 3 + Pinia + Vite 构建的渐进式单页应用,状态管理与 API 调用强绑定

frontend目录结构遵循“功能域优先”原则:

src/ ├── api/ # 自动生成的 API Client(由 backend/openapi.yaml 生成) │ ├── search.ts │ └── recommend.ts ├── stores/ # Pinia store,每个 store 对应一个业务域 │ ├── searchStore.ts # 封装 searchApi.search() 调用 + 分页状态 + 加载态 │ └── recommendStore.ts ├── views/ │ ├── SearchView.vue # 使用 searchStore,不直接调用 api │ └── RecommendView.vue └── router/index.ts # 路由守卫中注入全局埋点(见 4.3 节)

核心设计是“Store 即数据源”:searchStore.ts不仅管理keyword、page、loading状态,还封装了完整的请求逻辑:

// src/stores/searchStore.ts import { defineStore } from 'pinia' import { searchApi } from '@/api/search' export const useSearchStore = defineStore('search', { state: () => ({ keyword: '', page: 1, items: [] as SearchResultItem[], total: 0, loading: false, }), actions: { async fetch() { this.loading = true try { const res = await searchApi.search({ keyword: this.keyword, page: this.page }) this.items = res.data.items this.total = res.data.total } finally { this.loading = false } }, }, })

这种写法让组件极度轻量:SearchView.vue中只需const searchStore = useSearchStore(),然后searchStore.fetch(),无需关心 axios 配置、错误重试、Loading 控制——这些都在 Store 内聚完成。这是青训营强调的“关注点分离”:视图只管渲染,状态只管数据流,API 只管协议。

2.3 infra:Docker Compose 编排 + Nginx 反向代理 + GitHub Actions CI,拒绝“本地能跑就行”

infra/docker-compose.yml定义了 5 个服务:

services: nginx: image: nginx:alpine ports: ["80:80"] volumes: ["./nginx.conf:/etc/nginx/nginx.conf"] elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:8.11.3 environment: - discovery.type=single-node - xpack.security.enabled=false search-service: build: ../backend/search-service depends_on: [elasticsearch] recommend-service: build: ../backend/recommend-service gateway: build: ../backend/gateway ports: ["8080:8080"]

Nginx 配置(infra/nginx.conf)做了两件事:

  1. 将/api/前缀反向代理到gateway:8080(统一入口)
  2. 将/根路径指向frontend/dist静态资源(Vite 构建产物)

CI 脚本(.github/workflows/ci.yml)执行三步:

  • mvn clean compile(后端编译)
  • npm ci && npm run build(前端构建)
  • docker-compose build --no-cache(镜像构建)

注意:CI 中未包含自动化测试,但docs/testing-strategy.md明确要求:每个新接口必须在backend/*/src/test下补充@SpringBootTest集成测试,且覆盖率不得低于 65%。这是青训营的硬性交付红线。


3. 启动与调试全流程:从零开始 5 分钟跑通,重点看日志、端口、环境变量三要素

别急着docker-compose up。这个项目对环境敏感,很多第一次运行失败,根源不在代码,而在三个被忽略的“启动前检查点”。下面给出一条无脑可复现的路径,并标注每个环节的关键验证信号。

3.1 环境准备:JDK 17 + Node.js 18 + Docker Desktop(Mac/Win)或 Docker Engine(Linux)

  • JDK:必须为JDK 17(非 21 或 8)。backend/pom.xml中<java.version>17</java.version>是硬编码,若用 JDK 21 会报Unsupported class file major version 65。验证命令:
    java -version # 输出应含 "17.0.x" javac -version # 同上
  • Node.js:frontend/package.json中"engines": {"node": ">=18.0.0"},低于 18 会触发npm install报错ERR_UNSUPPORTED_ESM_URL_SCHEME。验证:
    node -v # v18.x.x npm -v # 9.x.x(npm 10+ 与 Vite 4.5 有兼容问题)
  • Docker:docker --version必须 ≥ 24.0。旧版 Docker Desktop(如 4.15)因内核升级问题,会导致elasticsearch容器启动失败并静默退出。验证:
    docker info | grep "Server Version" # 应输出 Server Version: 24.x.x

3.2 后端编译:跳过测试,但保留编译期代码生成

进入backend目录,执行:

mvn clean compile -DskipTests

关键点:

  • -DskipTests是必须的,因为recommend-service的测试依赖本地 Redis,而infra/docker-compose.yml中未启动 Redis(青训营认为“缓存一致性测试”属于进阶阶段,初学者先跑通主干)。
  • compile阶段会触发openapi-generator-maven-plugin,自动生成common/src/main/java/com/example/api/...下的 DTO 类和search-service/api/src/main/java/...下的 Feign Client 接口。若该步骤无声失败,后续search-service启动会报ClassNotFoundException。验证方法:检查search-service/api/target/generated-sources/openapi/目录是否存在,且内含SearchApi.java文件。

3.3 前端构建:Vite 需要显式指定 base 路径

进入frontend目录,执行:

npm ci npm run build

注意:vite.config.ts中base: '/static/'是硬编码,因为 Nginx 配置将静态资源挂载在/static/路径下(见infra/nginx.conf的location /static/块)。若你修改了 Nginx 的root,必须同步改此处,否则页面白屏且浏览器控制台报Failed to load resource: the server responded with a status of 404 ()。验证:frontend/dist/下应存在index.html,且其中<script type="module" src="/static/assets/index.xxxxxx.js">的src属性以/static/开头。

3.4 容器启动:观察日志流,定位失败服务

回到项目根目录,执行:

docker-compose up -d docker-compose logs -f

此时紧盯日志输出,重点关注三类信号:

  • ✅elasticsearch日志末尾出现started字样(表示 ES 启动成功)
  • ✅search-service日志出现Started SearchServiceApplication in X.XXX seconds(表示 Spring Boot 启动成功)
  • ❌ 若search-service日志卡在Connecting to Elasticsearch并超时,则大概率是elasticsearch未就绪,需等 30 秒再docker-compose restart search-service

最终验证:访问http://localhost/api/v1/health(Gateway 健康检查端点),返回{"status":"UP"}即代表网关、搜索、推荐三服务全部连通。


4. 关键模块代码精读:搜索服务的 Query DSL 构建、推荐服务的实时行为埋点、网关的 JWT 鉴权链路

光跑通不够,得知道“它为什么能跑通”。下面选取三个最具教学价值的模块,逐行解析其核心逻辑。这些代码不是为了炫技,而是青训营反复强调的“可维护性锚点”:当业务变化时,你该去改哪几行。

4.1 search-service:Elasticsearch 查询构建 —— 从字符串拼接到 QueryBuilder 的演进

搜索服务的核心在SearchService.java的search()方法。初版(v1.0)曾用字符串拼接构造 DSL,导致难以调试、无法复用、SQL 注入风险(虽 ES 无 SQL,但恶意 keyword 可触发 script 异常)。当前版本(v2.2)已重构为QueryBuilder链式调用:

// backend/search-service/src/main/java/com/example/search/SearchService.java public SearchResponse search(SearchRequest request) { // 1. 构建 must 查询:关键词全文匹配 QueryBuilder keywordQuery = QueryBuilders.matchQuery("title", request.getKeyword()) .operator(Operator.AND); // 强制所有词都出现 // 2. 构建 filter 查询:价格区间(不参与相关度打分) QueryBuilder priceFilter = QueryBuilders.rangeQuery("price") .from(request.getMinPrice()) .to(request.getMaxPrice()); // 3. 合并:must + filter BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() .must(keywordQuery) .filter(priceFilter); // 4. 构建 SearchSourceBuilder(分页、高亮等) SearchSourceBuilder sourceBuilder = new SearchSourceBuilder() .query(boolQuery) .from((request.getPage() - 1) * 20) // 页码转 offset .size(20) .highlighter(new HighlightBuilder().field("title")); // 标题高亮 SearchRequest esRequest = new SearchRequest("products"); esRequest.source(sourceBuilder); // 5. 执行并转换结果 SearchResponse esResponse = restHighLevelClient.search(esRequest, RequestOptions.DEFAULT); return convertToSearchResponse(esResponse); }

逻辑说明:

  • must子句影响_score(相关度),用于关键词匹配;filter子句不计算得分,但利用 ES 的缓存机制提升性能,适合价格、分类等确定性条件。
  • from/size是浅分页,青训营备注:“仅用于教学演示,生产环境必须用search_after替代,避免深度分页 OOM”。
  • convertToSearchResponse()是一个纯数据转换方法,将SearchHit[]映射为SearchResponseDTO,不包含任何业务逻辑,确保可测试性。

4.2 recommend-service:用户行为埋点的异步上报与降级策略

推荐服务的实时性依赖用户行为(点击、加购、收藏)。RecommendController.java中的getRecommendations()方法,在返回推荐列表前,会异步记录本次请求的上下文:

// backend/recommend-service/src/main/java/com/example/recommend/RecommendController.java @GetMapping("/v1/recommend") public ResponseEntity<RecommendResponse> getRecommendations( @RequestHeader("X-User-ID") String userId, @RequestParam String scene) { // 1. 主流程:生成推荐 List<RecommendItem> items = recommendEngine.generate(userId, scene); // 2. 异步埋点:记录本次推荐请求(非阻塞主流程) behaviorTracker.trackRecommendRequest(userId, scene, items.size()); return ResponseEntity.ok(new RecommendResponse(items)); }

behaviorTracker是一个@ServiceBean,其实现trackRecommendRequest()方法如下:

// backend/recommend-service/src/main/java/com/example/recommend/BehaviorTracker.java @Async // Spring @Async,走线程池 public void trackRecommendRequest(String userId, String scene, int itemCount) { try { // 发送 HTTP 请求到埋点收集服务(infra 中未提供,此处 mock 为日志) log.info("Track recommend: userId={}, scene={}, count={}", userId, scene, itemCount); } catch (Exception e) { // 降级:记录错误,但绝不抛出,保证主流程不受影响 log.warn("Failed to track recommend request", e); } }

参数说明:

  • @Async注解要求@EnableAsync在主配置类中启用,且线程池需自定义(TaskExecutorBean),项目中已配置corePoolSize=5,避免线程爆炸。
  • log.info是降级兜底,生产环境此处应替换为 Kafka Producer 或 HTTP Client 发往专用埋点服务。青训营强调:“埋点失败不能成为推荐失败的理由,这是 SLA 的底线”。

4.3 gateway:JWT 鉴权的 Filter 链与 Token 解析细节

网关的鉴权逻辑在AuthFilter.java中,它继承AbstractGatewayFilterFactory,是一个标准的 Spring Cloud Gateway Filter:

// backend/gateway/src/main/java/com/example/gateway/filter/AuthFilter.java public class AuthFilter extends AbstractGatewayFilterFactory<AuthFilter.Config> { private final JwtDecoder jwtDecoder; // Spring Security 提供的 JWT 解码器 public AuthFilter(JwtDecoder jwtDecoder) { super(Config.class); this.jwtDecoder = jwtDecoder; } @Override public GatewayFilter apply(Config config) { return (exchange, chain) -> { String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } String token = authHeader.substring(7); // 去掉 "Bearer " try { Jwt jwt = jwtDecoder.decode(token); // 此处校验 signature、exp、iss // 将用户 ID 放入 ServerWebExchange 的 attributes,供下游服务获取 exchange.getAttributes().put("user_id", jwt.getClaimAsString("sub")); return chain.filter(exchange); } catch (JwtException e) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } }; } public static class Config {} }

关键点:

  • jwtDecoder是NimbusJwtDecoder实例,密钥来自application.yml的spring.security.oauth2.resourceserver.jwt.jwk-set-uri,青训营使用本地 JWK Set(infra/jwks.json)模拟,避免依赖外部 OAuth2 服务。
  • exchange.getAttributes().put("user_id", ...)是网关向下游透传用户身份的标准方式,search-service中可通过ServerWebExchange.getAttributes().get("user_id")获取,无需重复解析 Token,减少下游服务的 CPU 开销。
  • 整个 Filter 是响应式(Mono<Void>),符合 WebFlux 风格,不会阻塞 Netty 线程。

5. 避坑指南:新手必踩的 4 个血泪现场,以及我们如何用 3 行配置修复

这个项目看似结构清晰,但新手在首次运行、二次开发、联调时,有 4 个高频翻车点。它们不是 Bug,而是青训营刻意设计的“认知门槛”,用来检验你是否真正理解了模块边界与配置含义。下面列出现象、根因与一击必杀的修复方案。

5.1 现象:前端页面空白,浏览器 Network 面板显示GET http://localhost/static/assets/index.xxxxxx.js net::ERR_ABORTED 404

  • 原因:Nginx 配置中root路径与 Vite 构建产物实际位置不匹配。infra/nginx.conf的location /static/块指定了root /app/frontend/dist;,但docker-compose.yml中nginx服务的 volume 挂载是./frontend/dist:/app/frontend/dist。如果frontend/dist目录在容器启动前不存在(即你没先npm run build),Nginx 会创建一个空目录,导致root指向空文件夹,所有静态资源 404。
  • 解决:在docker-compose up前,必须在宿主机执行npm run build,确保frontend/dist目录存在且非空。验证命令:ls -l frontend/dist | head -5应看到index.html和assets/目录。

5.2 现象:search-service启动时报NoNodeAvailableException,日志显示Failed to connect to http://elasticsearch:9200

  • 原因:Docker 网络中服务发现失败。search-service的application.yml中spring.elasticsearch.uris=http://elasticsearch:9200,但elasticsearch容器启动慢于search-service,导致search-service初始化时 DNS 解析失败。这不是代码问题,而是启动时序问题。
  • 解决:在docker-compose.yml中为search-service添加健康检查与依赖:
    search-service: build: ../backend/search-service depends_on: elasticsearch: condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] interval: 30s timeout: 10s retries: 5
    同时在elasticsearch服务下添加healthcheck(ES 自带/_cat/health?h=st端点)。

5.3 现象:调用/api/v1/search?keyword=test返回500 Internal Server Error,日志显示NullPointerException在SearchService.search()第 42 行

  • 原因:SearchRequestDTO 的page字段为null,而代码中request.getPage() - 1导致 NPE。青训营故意留此空指针,考察你是否理解 Spring Boot 的@Valid与@NotNull约束。
  • 解决:在SearchRequest.java的page字段上添加校验注解:
    public class SearchRequest { private String keyword; @NotNull(message = "page 不能为空") @Min(value = 1, message = "page 必须 >= 1") private Integer page = 1; // 并设默认值,防 null // ... }
    并在 Controller 方法参数前加@Valid:
    public SearchResponse search(@Valid @RequestBody SearchRequest request) { ... }

5.4 现象:gateway日志疯狂打印Invalid JWT token,但curl -H "Authorization: Bearer xxx"测试正常

  • 原因:前端 Vue 应用在发送请求时,未携带AuthorizationHeader。router/index.ts中的全局前置守卫未正确注入 token。青训营的useAuthStore()返回的token是响应式 ref,但axios拦截器中直接取.value时机不对。
  • 解决:修改frontend/src/utils/request.ts中的请求拦截器:
    // 错误写法(取值时机错) // config.headers.Authorization = `Bearer ${authStore.token.value}`; // 正确写法(使用 computed,自动响应 token 变化) const authStore = useAuthStore(); config.headers.Authorization = `Bearer ${authStore.token}`;
    因为authStore.token是一个Ref<string>,axios会自动解包其.value,无需手动调用。

6. 二次开发实战:如何安全地为推荐服务增加“用户画像标签”能力,并验证其在线效果

现在你已跑通、理解结构、避开常见坑,下一步就是动手改。青训营最后一个实战任务是:为recommend-service增加“用户画像标签”能力,使其能根据用户历史行为(点击、加购)动态打标,并在推荐结果中透传userTags: ["high-price", "fashion-lover"]字段。这不是加个字段那么简单,它考验你对数据流、服务边界、可观测性的综合把握。下面是我的实操路径。

6.1 数据模型扩展:新增UserTag实体与存储策略

首先,在common模块中定义UserTag:

// backend/common/src/main/java/com/example/common/entity/UserTag.java public record UserTag( String userId, String tag, Integer weight, // 权重,0-100 LocalDateTime updatedAt ) {}

存储策略选择:青训营不强制用 Redis 或数据库,而是采用“内存 Map + 定时持久化”的轻量方案,降低初学者复杂度。在recommend-service中新建UserTagService:

// backend/recommend-service/src/main/java/com/example/recommend/service/UserTagService.java @Service public class UserTagService { // 内存存储,key: userId, value: Map<tag, weight> private final ConcurrentMap<String, Map<String, Integer>> userTagCache = new ConcurrentHashMap<>(); // 定时任务:每 5 分钟将内存标签刷入文件(模拟持久化) @Scheduled(fixedDelay = 300_000) public void persistTags() { try (ObjectOutputStream oos = new ObjectOutputStream( new FileOutputStream("user-tags.dat"))) { oos.writeObject(userTagCache); } catch (IOException e) { log.error("Failed to persist user tags", e); } } public void addTag(String userId, String tag, int weight) { userTagCache.computeIfAbsent(userId, k -> new ConcurrentHashMap<>()) .merge(tag, weight, Integer::sum); // 累加权重 } public List<String> getTopTags(String userId, int limit) { return userTagCache.getOrDefault(userId, Map.of()).entrySet().stream() .sorted(Map.Entry.<String, Integer>comparingByValue().reversed()) .limit(limit) .map(Map.Entry::getKey) .collect(Collectors.toList()); } }

注意:ConcurrentHashMap保证线程安全,@Scheduled需在主类加@EnableScheduling。青训营强调:“标签计算是离线的,实时性要求不高,不必上 Flink”。

6.2 推荐引擎增强:在generate()方法中注入标签逻辑

修改RecommendEngine.java的generate()方法,在生成推荐列表后,追加用户标签:

// backend/recommend-service/src/main/java/com/example/recommend/engine/RecommendEngine.java public List<RecommendItem> generate(String userId, String scene) { List<RecommendItem> items = baseAlgorithm.generate(userId, scene); // 新增:注入用户标签 List<String> userTags = userTagService.getTopTags(userId, 3); items.forEach(item -> item.setUserTags(userTags)); return items; }

同时,更新RecommendItemDTO,添加userTags字段:

// backend/common/src/main/java/com/example/common/dto/RecommendItem.java public class RecommendItem { private String id; private String title; private BigDecimal price; private List<String> userTags = new ArrayList<>(); // 新增 // getter/setter... }

6.3 前端消费:Pinia Store 与 Vue 组件联动展示标签

在frontend/src/stores/recommendStore.ts中,RecommendResponse的items已自动包含userTags(因 OpenAPI 生成),只需在组件中渲染:

<!-- frontend/src/views/RecommendView.vue --> <template> <div v-for="item in recommendStore.items" :key="item.id"> <h3>{{ item.title }}</h3> <p>¥{{ item.price }}</p> <!-- 新增:标签展示 --> <div class="tags"> <span v-for="tag in item.userTags" :key="tag" class="tag">{{ tag }}</span> </div> </div> </template> <script setup> import { useRecommendStore } from '@/stores/recommendStore' const recommendStore = useRecommendStore() recommendStore.fetch() </script>

6.4 效果验证:三步确认改动已生效

  1. 本地验证:启动服务后,用 Postman 调用GET http://localhost/api/v1/recommend?scene=home,检查响应 JSON 中每个item是否含"userTags": [...]字段。若为空,检查UserTagService.addTag()是否被调用(可在埋点日志中加log.info("Add tag: {}", tag))。
  2. 行为触发:前端点击某个商品,触发behaviorTracker.trackClick()(项目中已有此方法),该方法内部应调用userTagService.addTag(userId, "click-" + itemId, 10)。等待 5 分钟(@Scheduled周期),再请求推荐,标签应出现。
  3. 线上观测:青训营提供简易 Prometheus + Grafana 模板(infra/grafana/),导入后查看recommend_user_tag_count{userId="xxx"}指标,确认标签数量随点击增长。这是你第一次亲手建立“行为 → 标签 → 推荐”的正向闭环。

从那以后我每次给新人讲推荐系统,都不再从矩阵分解讲起,而是先带他们跑通这个addTag()方法,看着userTags从空数组变成["click-shoe-123", "high-price"],再变成["fashion-lover", "high-price"]。数据不会说谎,标签不会骗人,当userTags真实出现在推荐卡片上,那种“我造出了东西”的手感,比任何理论都扎实。希望帮到你。

本文还有配套的精品资源,点击获取