大脑活动记录系统:Spring Boot + MySQL 后端开发实战

发布时间:2026/8/29 19:13:39
大脑活动记录系统:Spring Boot + MySQL 后端开发实战
前几天在 Hacker News 的 Show HN 板块看到 Edgi 这个项目时我第一反应是被它的介绍语戳中了Letterboxd or Strava for your brain。用一句话解释它是想给“大脑活动”做一个记录与追踪平台。电影爱好者会在 Letterboxd 里记录看过的电影、打分、写短评跑步骑车的人会在 Strava 上打卡、看里程、冲击赛季榜单而 Edgi 想做的事情是把你读过的文章、看过的视频、学过的课程甚至临时冒出来的一条想法也变成一条一条可以管理、可以统计、可以分享的记录。这个思路听起来并不复杂但真要把“给大脑做记录”的产品落地会同时涉及产品定位、数据建模、聚合统计、成就激励和前后端开发是一个非常适合拿来做全栈练习的方向。本文就围绕 Edgi 背后的产品概念展开从零实现一个最小可用的“大脑活动记录系统”。你不需要看过 Edgi 的源码也不需要拥有产品经理背景只要具备基础的 Java 或后端开发经验就能跟着本文一步步把核心后端搭建出来即使是新手也能通过这篇文章理解一个内容记录类产品的完整技术链路。需要先说明的是Edgi 本身是一个独立产品本文不会去复刻它的源码而是基于公开的产品定位演示如何自行设计和实现同类能力。我们会把“记录”、“统计”、“成就”三个核心环节拆开讲解再通过 Spring Boot MySQL 的完整代码示例把它串成一个可以运行的项目。1. Edgi 是什么给大脑做一本“观影日记”1.1 先理解 Letterboxd 和 Strava 这两个参照物Letterboxd是一个电影爱好者社区核心功能是记录观影历史、打分、写短评和日记。用户每看完一部电影就会创建一条记录标注影片名称、评分、日期和观后感。时间久了这些零散记录会形成一份完整的个人观影档案系统还会生成年度回顾、观影排行榜和“看过但你还没看”的好友推荐。Strava是运动社交应用用户跑步或骑行后上传 GPS 轨迹应用会自动计算距离、配速、爬升和卡路里并生成排行榜、勋章和分段成绩。它对普通用户最大的吸引力不是“记录”本身而是把运动数据变成可视化图表和可追逐的目标。这两个产品虽然场景完全不同但产品骨架高度一致一个标准化的“活动对象”电影或运动记录。一条按时间排列的个人时间线。一组自动计算的统计指标。一套基于里程碑的成就激励体系。用户之间的关注、点赞和评论关系。Edgi 正是把同一套骨架迁移到了“大脑活动”上。它要回答的问题是你今天的注意力花在哪里你读了几篇高质量文章学到了什么概念产生了哪些值得回看的想法这些活动不像电影和运动那样有明确的元数据片名、导演、里程、配速所以这类产品需要更强的主动记录机制也需要更灵活的标签和类型体系来承载“大脑活动”天然的模糊性。1.2 Edgi 的产品定位从产品定位上看Edgi 想做的是“自我量化的下一步”。运动数据已经被 Strava 量化得很彻底了屏幕使用时间也已经被系统自动化统计了但“今天我学到了什么”这件事仍然停留在零散的笔记软件和备忘录里。Edgi 的切入点是把“内容消费”和“思考产出”变成结构化记录。用户读完一本书、看了一部纪录片、听了一期播客、读完一篇长文都可以创建一条 activity 记录并给它打上标签、评分和情绪标记。后续再通过周报、月报、年度回顾让用户看到自己的认知投入分布。这种设计与 Letterboxd 的“年度观影报告”非常类似只不过统计对象从电影变成了知识内容。对于开发者来说这类产品最大的工程价值在于它把一个真实项目的常见后端问题都集中了多用户数据隔离怎么做。标签和分类用什么数据模型。时间线列表如何分页。统计数据如何聚合和缓存。成就徽章如何避免重复发放。用户隐私字段如何控制可见性。这些问题在普通 CRUD 项目中不会全部出现但做一个类似 Edgi 的记录类产品时它们都会轮番出现。1.3 这类应用解决的问题记录本身不是目的记录带来的反馈才是。第一没有记录就没有反馈。很多人高估了自己一周内真正投入学习的时间。当你把读过的文章、看过的课程逐条记录下来周末回看时间线时才发现“看起来很忙”和“实际学了什么”之间差距有多大。第二没有统计就没有优化。只有当你按类型、时长、评分维度拆解自己的记录才能意识到这周看了太多短视频内容而深度阅读时间不足。第三没有追踪就很难坚持。连续打卡、成就徽章、历史数据曲线这些游戏化机制能把“学习”变成一个可感知的进度条支持长期坚持。Edgi 这类产品本质上是在解决“注意力资产的记账问题”。它不需要像企业级系统那样复杂但需要足够灵活的数据模型来记录各种不同类型的思维活动。2. 产品功能拆分与数据模型设计2.1 核心功能模块从用户视角出发一个 Edgi 类产品的 MVP 可以拆成以下七个模块模块说明优先级用户体系注册、登录、个人资料高活动记录创建一条思维活动记录高分类标签给活动打上类型和标签高时间线按时间倒序展示个人记录高数据统计总量、类型分布、连续天数中成就体系里程碑徽章和打卡奖励中搜索筛选按类型、标签、时间范围过滤低在本文的示例中我们重点实现用户体系之后的活动记录、时间线、数据统计和成就判断搜索筛选会给出设计建议但先不展开完整实现。2.2 核心数据模型根据功能模块数据模型可以抽象为四张核心表。用户表users存储用户基础信息。示例中只保留 username、nickname 和创建时间实际项目还需要加密密码、头像、邮箱等字段。活动记录表activities这是整张数据模型中最重要的表。每一条记录代表一次“大脑活动”字段包括type活动类型例如 article、book、video、course、note。title活动标题。content详细描述或笔记。rating1 到 5 星评分。duration_minutes投入时长。created_at记录时间。标签表tags与关联表activity_tags一个活动可以有多个标签一个标签也可以挂在多个活动下所以使用多对多关系。用关联表而不是直接在活动表里存一个“标签字符串”是为了后续能做标签维度的统计筛选。成就表achievements记录用户已获得的徽章。设计上需要保证同一用户同一徽章只能获得一次所以通常会有user_id code的唯一约束。2.3 数据流与状态变化一次完整的创建记录流程如下前端把用户输入的活动信息提交到后端。后端校验必填字段例如 userId、type、title。后端补全 created_at 并写入 activities 表。写入成功后触发统计相关逻辑。判断用户是否达到新的成就条件。如果达到在 achievements 表插入一条记录。返回给前端创建成功的活动对象。这个流程看起来简单但有一个坑点成就判断必须在活动保存成功后、事务提交前执行才能保证活动和徽章同时生效或同时失败。在后端实现时建议把这两步包在同一个事务方法中。3. 环境准备与技术选型3.1 技术栈选择既然目标是演示一个最小可运行的后端系统我选择了在中文开发者社区中最常见的组合JDK 11 或 JDK 17。Spring Boot 2.7.x快速搭建 Web 项目。Spring Data JPA 作为 ORM减少 SQL 样板代码。MySQL 8.0 作为主数据库。H2 作为本地快速演示数据库。Maven 管理依赖。curl 或 Postman 做接口验证。使用 Spring Boot 的原因很简单它对新手友好开发效率高并且自动配置了 Web、数据库连接池和事务管理让我们能把精力集中在业务代码上。3.2 版本说明本文示例使用 Spring Boot 2.7.18搭配 Java 11。如果你使用 Spring Boot 3.x需要把 JDK 升级到 17 或更高版本同时把javax.persistence的相关包名替换为jakarta.persistence其他业务逻辑基本不受影响。版本需要根据你的实际项目调整本文示例以常见环境为例重点演示配置思路而不是绑定死某一套版本组合。3.3 示例项目结构edgi-demo/ ├── pom.xml └── src/main/java/com/example/edgi/ ├── EdgiApplication.java ├── entity/ │ ├── User.java │ ├── Activity.java │ ├── Tag.java │ └── Achievement.java ├── repository/ │ ├── ActivityRepository.java │ └── AchievementRepository.java ├── service/ │ └── ActivityService.java └── controller/ └── ActivityController.java实际项目中还会增加 dto、vo、exception 等分层目录。这里为了阅读方便我们把关键内容集中展示。4. 数据库设计与实现4.1 表结构设计数据库设计决定了一个记录类产品后续能走多远。这里有几个关键选择。第一个选择是type 字段到底用字符串还是枚举。我建议在数据库层使用字符串因为后续新增活动类型不需要执行数据库变更在 Java 代码中则可以考虑枚举或常量类保证代码可读性。第二个选择是活动内容 content 字段存什么。对于笔记型内容可以直接用 TEXT 类型存纯文本。如果需要存储 Markdown 原文或超链接建议再增加source_url、content_type等字段。如果内容量非常大甚至可以把正文拆到独立的 activity_contents 表中避免列表查询时拖慢 I/O。第三个选择是成就表的唯一约束。要避免用户重复获得同一个徽章光靠应用层判断不够稳数据库层面应该加上联合唯一索引。4.2 建表 SQL下面是一份可以直接执行的 MySQL 建表语句。CREATE DATABASE IF NOT EXISTS edgi_db DEFAULT CHARACTER SET utf8mb4; USE edgi_db; CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL, nickname VARCHAR(64), created_at DATETIME NOT NULL, UNIQUE KEY uk_users_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE activities ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, type VARCHAR(32) NOT NULL, title VARCHAR(128) NOT NULL, content TEXT, rating INT, duration_minutes INT, created_at DATETIME NOT NULL, KEY idx_user_created (user_id, created_at), CONSTRAINT fk_activities_user FOREIGN KEY (user_id) REFERENCES users(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tags ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, UNIQUE KEY uk_tags_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE activity_tags ( activity_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, PRIMARY KEY (activity_id, tag_id), KEY idx_tag_id (tag_id), CONSTRAINT fk_activity_tags_activity FOREIGN KEY (activity_id) REFERENCES activities(id), CONSTRAINT fk_activity_tags_tag FOREIGN KEY (tag_id) REFERENCES tags(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE achievements ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, code VARCHAR(64) NOT NULL, name VARCHAR(128) NOT NULL, earned_at DATETIME NOT NULL, UNIQUE KEY uk_user_code (user_id, code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;4.3 字段设计与索引说明activities表上的idx_user_created (user_id, created_at)是一个非常典型的联合索引。时间线的查询条件通常是“查某个用户的活动按时间倒序”所以把 user_id 放在联合索引最左侧可以快速定位到目标用户再通过 created_at 保持排序顺序避免额外的文件排序。activity_tags使用复合主键(activity_id, tag_id)这是多对多关联表的常规做法。同时因为后续可能会根据标签反查活动列表所以单独为 tag_id 建立索引让反向查询也能走索引。achievements表上(user_id, code)的唯一索引是防止重复发徽章的最终防线。即使业务代码中因为并发同时触发两次判断数据库也会拒绝第二条插入。5. 后端核心实现5.1 添加依赖与配置先创建pom.xml引入 Spring Boot Web、Spring Data JPA、MySQL 驱动和 H2 测试依赖。?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdedgi-demo/artifactId version0.0.1-SNAPSHOT/version parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version11/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project然后编写src/main/resources/application.yml。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/edgi_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true如果你本地没有 MySQL也可以临时把spring.datasource.url切换为 H2 的 JDBC 地址例如jdbc:h2:mem:edgi这样无需安装数据库就能先跑通接口。5.2 实体类代码在entity包下创建活动实体类。// 文件路径src/main/java/com/example/edgi/entity/Activity.java package com.example.edgi.entity; import javax.persistence.*; import java.time.LocalDateTime; Entity Table(name activities, indexes { Index(name idx_user_created, columnList user_id, created_at) }) public class Activity { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name user_id, nullable false) private Long userId; Column(nullable false, length 32) private String type; Column(nullable false, length 128) private String title; Column(columnDefinition TEXT) private String content; private Integer rating; Column(name duration_minutes) private Integer durationMinutes; Column(name created_at, nullable false) private LocalDateTime createdAt; public Long getId() { return id; } public void setId(Long id) { this.id id; } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId userId; } public String getType() { return type; } public void setType(String type) { this.type type; } public String getTitle() { return title; } public void setTitle(String title) { this.title title; } public String getContent() { return content; } public void setContent(String content) { this.content content; } public Integer getRating() { return rating; } public void setRating(Integer rating) { this.rating rating; } public Integer getDurationMinutes() { return durationMinutes; } public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes durationMinutes; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt createdAt; } }为了控制篇幅User、Tag、Achievement 三个实体类不逐行展开核心结构如下。// 文件路径src/main/java/com/example/edgi/entity/Achievement.java package com.example.edgi.entity; import javax.persistence.*; import java.time.LocalDateTime; Entity Table(name achievements, uniqueConstraints UniqueConstraint(columnNames {user_id, code})) public class Achievement { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name user_id, nullable false) private Long userId; Column(nullable false, length 64) private String code; Column(nullable false, length 128) private String name; Column(name earned_at, nullable false) private LocalDateTime earnedAt; // getter / setter 省略 }5.3 数据访问层创建ActivityRepository继承 JpaRepository并声明几个自定义查询方法。// 文件路径src/main/java/com/example/edgi/repository/ActivityRepository.java package com.example.edgi.repository; import com.example.edgi.entity.Activity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; public interface ActivityRepository extends JpaRepositoryActivity, Long { ListActivity findByUserIdOrderByCreatedAtDesc(Long userId); ListActivity findByUserIdAndCreatedAtAfter(Long userId, LocalDateTime from); long countByUserId(Long userId); Query(select a.type, count(a) from Activity a where a.userId :userId group by a.type) ListObject[] countByType(Param(userId) Long userId); Query(select coalesce(sum(a.durationMinutes), 0) from Activity a where a.userId :userId and a.durationMinutes is not null) Integer sumDurationByUserId(Param(userId) Long userId); }由于Activity表中的userId是普通字段而不是通过ManyToOne关联的实体对象所以上面的 JPQL 写法是合法的。如果你的项目使用了真正的实体关联也可以直接写a.user.id。再创建 AchievementRepository。// 文件路径src/main/java/com/example/edgi/repository/AchievementRepository.java package com.example.edgi.repository; import com.example.edgi.entity.Achievement; import org.springframework.data.jpa.repository.JpaRepository; public interface AchievementRepository extends JpaRepositoryAchievement, Long { boolean existsByUserIdAndCode(Long userId, String code); }5.4 业务层代码业务层是整篇文章的核心。ActivityService 负责创建活动、查询时间线、统计数据和成就判断。// 文件路径src/main/java/com/example/edgi/service/ActivityService.java package com.example.edgi.service; import com.example.edgi.entity.Achievement; import com.example.edgi.entity.Activity; import com.example.edgi.repository.AchievementRepository; import com.example.edgi.repository.ActivityRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; Service public class ActivityService { private final ActivityRepository activityRepository; private final AchievementRepository achievementRepository; public ActivityService(ActivityRepository activityRepository, AchievementRepository achievementRepository) { this.activityRepository activityRepository; this.achievementRepository achievementRepository; } Transactional public Activity create(Activity activity) { activity.setId(null); if (activity.getCreatedAt() null) { activity.setCreatedAt(LocalDateTime.now()); } Activity saved activityRepository.save(activity); checkAndGrantAchievements(saved.getUserId()); return saved; } public ListActivity listByUser(Long userId) { return activityRepository.findByUserIdOrderByCreatedAtDesc(userId); } public void delete(Long id) { activityRepository.deleteById(id); } public MapString, Object stats(Long userId) { MapString, Object result new HashMap(); long total activityRepository.countByUserId(userId); Integer totalMinutes activityRepository.sumDurationByUserId(userId); ListObject[] byType activityRepository.countByType(userId); result.put(userId, userId); result.put(totalActivities, total); result.put(totalDurationMinutes, totalMinutes null ? 0 : totalMinutes); result.put(typeDistribution, byType); return result; } public long activeDaysLastSevenDays(Long userId) { LocalDateTime since LocalDateTime.now().minusDays(7); return activityRepository.findByUserIdAndCreatedAtAfter(userId, since) .stream() .map(activity - activity.getCreatedAt().toLocalDate()) .distinct() .count(); } private void checkAndGrantAchievements(Long userId) { long total activityRepository.countByUserId(userId); if (total 10) { grantIfAbsent(userId, ACTIVITY_10, 记录 10 条思维活动); } Integer minutes activityRepository.sumDurationByUserId(userId); if (minutes ! null minutes 600) { grantIfAbsent(userId, DURATION_10H, 累计投入 10 小时); } long activeDays activeDaysLastSevenDays(userId); if (activeDays 5) { grantIfAbsent(userId, WEEK_5DAYS, 一周内连续记录 5 天); } } private void grantIfAbsent(Long userId, String code, String name) { if (!achievementRepository.existsByUserIdAndCode(userId, code)) { Achievement achievement new Achievement(); achievement.setUserId(userId); achievement.setCode(code); achievement.setName(name); achievement.setEarnedAt(LocalDateTime.now()); achievementRepository.save(achievement); } } }这里有几个值得展开讲的设计点。第一为什么 create 方法要使用 Transactional因为同一个方法内既有活动保存又有成就发放。如果不加事务就可能出现“活动保存成功、但徽章发放失败”的中间状态。加上事务后只要其中一个步骤抛出异常整个操作都会回滚数据一致性得到保证。第二成就判断为什么要通过 existsByUserIdAndCode 再插入这是为了在数据库已有唯一约束的情况下尽量提前拦截重复插入。真正的兜底是数据库层的(user_id, code)联合唯一索引应用层判断只是一个优化避免大量无意义的唯一冲突异常。第三activeDaysLastSevenDays 方法的写法有什么问题这个实现先把最近 7 天的所有活动查询到内存再通过 Java Stream 去重统计天数。在数据量小的时候完全没问题但活动量达到数十万条后这种查询会非常浪费。更优的方案是在 SQL 层面直接使用COUNT(DISTINCT DATE(created_at))完成统计本文为了可读性才保留 Java 写法。5.5 控制器层代码创建统一返回结果类 Result让接口响应保持一致。// 文件路径src/main/java/com/example/edgi/common/Result.java package com.example.edgi.common; public class ResultT { private int code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 0; r.message success; r.data data; return r; } public static T ResultT fail(String message) { ResultT r new Result(); r.code 1; r.message message; r.data null; return r; } // getter / setter }创建 ActivityController。// 文件路径src/main/java/com/example/edgi/controller/ActivityController.java package com.example.edgi.controller; import com.example.edgi.common.Result; import com.example.edgi.entity.Activity; import com.example.edgi.service.ActivityService; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; RestController RequestMapping(/api/activities) public class ActivityController { private final ActivityService activityService; public ActivityController(ActivityService activityService) { this.activityService activityService; } PostMapping public ResultActivity create(RequestBody Activity activity) { return Result.ok(activityService.create(activity)); } GetMapping public ResultListActivity list(RequestParam Long userId) { return Result.ok(activityService.listByUser(userId)); } GetMapping(/stats) public ResultMapString, Object stats(RequestParam Long userId) { return Result.ok(activityService.stats(userId)); } DeleteMapping(/{id}) public ResultVoid delete(PathVariable Long id) { activityService.delete(id); return Result.ok(null); } }到这里我们已经拥有了一个最小可运行的后端 API创建活动、查询时间线、删除活动、查看统计数据。5.6 启动类最后创建启动类。// 文件路径src/main/java/com/example/edgi/EdgiApplication.java package com.example.edgi; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class EdgiApplication { public static void main(String[] args) { SpringApplication.run(EdgiApplication.class, args); } }6. 数据统计与成就体系6.1 统计维度设计在 Strava 这类产品中统计数据是用户留存的核心动力。Edgi 类产品应该至少支持以下几种统计维度。总量统计用户一共创建了多少条记录。这是最基础的数据也是成就判断的第一依据。时长统计累计投入分钟数。这里要注意并不是所有活动都有清晰的时长因为一些“想法记录”可能只有内容没有耗时所以统计时需要用COALESCE忽略空值。类型分布按类型统计数量例如文章、书籍、视频、课程各自占比帮助用户看清注意力分配。周期活跃度最近 7 天内有几天创建了记录这是连续打卡功能的基础。评分分布统计用户记录中 1 星到 5 星的分布帮助用户理解自己对不同类型内容的主观评价。6.2 接口返回示例我们设计的/api/activities/stats接口返回结构大致如下{ code: 0, message: success, data: { userId: 1, totalActivities: 12, totalDurationMinutes: 780, typeDistribution: [ [article, 5], [book, 4], [video, 3] ] } }前端拿到这份数据后可以很方便地用饼图或柱状图渲染。typeDistribution 的每个数组元素就是一组“类型 数量”数据。6.3 成就触发时机与扩展思路本文示例中的成就触发发生在create()方法内部也就是用户创建活动成功后立刻判断。这种实现简单直接缺点是如果未来统计逻辑变复杂create()方法会越来越臃肿。更工程化的做法是把成就判断拆成独立的AchievementService并基于 Spring 的事件监听机制解耦。例如活动保存成功后发布一个ActivityCreatedEventAchievementService监听该事件再执行异步奖励判断。这样创建活动的核心链路不会被成就逻辑拖慢也方便后续增加更多奖励规则。成就规则本身也应该做成配置化而不是硬编码在 Java 中。可以把规则表设计为achievement_rules包含 code、名称、条件类型、条件阈值等字段由后台管理系统维护。这样新增徽章就不用改代码和发版。7. 运行验证与演示效果7.1 启动服务在项目根目录执行mvn spring-boot:run启动日志中出现Started EdgiApplication后说明后端服务已经在 8080 端口运行。7.2 创建活动记录通过 curl 调用新增接口。curl -X POST http://localhost:8080/api/activities \ -H Content-Type: application/json \ -d { userId: 1, type: book, title: 《置身事内》, content: 读完前两章理解了地方财政与经济发展的关系。, rating: 4, durationMinutes: 60 }预期响应如下{ code: 0, message: success, data: { id: 1, userId: 1, type: book, title: 《置身事内》, content: 读完前两章理解了地方财政与经济发展的关系。, rating: 4, durationMinutes: 60, createdAt: 2025-01-01T21:30:00 } }7.3 查询时间线与统计curl http://localhost:8080/api/activities?userId1curl http://localhost:8080/api/activities/stats?userId1此时 stats 接口会返回该用户的记录总数、总时长和类型分布。如果你已经录入了 10 条活动成就表中也会自动出现ACTIVITY_10徽章。8. 常见问题与排查思路问题现象常见原因解决思路启动时报端口被占用其他进程占用 8080 端口修改server.port或使用lsof -i :8080查找进程数据库连接失败MySQL 未启动、密码错误、数据库不存在检查 MySQL 服务状态确认edgi_db已创建核对 application.yml 配置中文保存后出现乱码数据库连接 URL 未指定 UTF-8 编码在 JDBC URL 中增加useUnicodetruecharacterEncodingutf8统计接口的 duration 为 null表中记录都未填写 duration_minutes前端创建活动时引导用户填写时长统计逻辑用COALESCE兜底成就徽章重复发放数据库没有唯一约束或判断逻辑存在并发问题在 achievements 表建立(user_id, code)唯一索引并在写入前做存在性检查时间线列表越查越慢user_id、created_at 未建联合索引为 activities 表添加idx_user_created联合索引使用 Spring Boot 3.x 后编译报错javax.persistence 包名不存在将javax.persistence替换为jakarta.persistence排查这类问题有一个通用思路先看启动日志再确认配置最后检查数据库。启动日志中如果出现Caused by通常那一行才是真正的根因不要只看最上面的异常堆栈。9. 最佳实践与工程建议9.1 数据建模建议不要让活动类型变成一张大表。在 MVP 阶段type字段用字符串常量即可不要过早引入动态类型表。如果后续业务确实需要用户自定义活动类型再通过 options 配置表扩展。不要把所有用户自定义字段都堆在一张表里。比如“书籍作者”、“文章链接”、“播客剧集号”这些字段如果直接加到 activities 表中会出现大量稀疏列。更合理的做法是增加一个extra_dataJSON 字段或者为高频类型建立子表。内容字段与大文本拆开。如果你的用户会写长笔记、Markdown 博客式记录建议把正文单独放到activity_contents表中通过 activity_id 一对一关联。这样列表接口查询时不会加载大文本字段I/O 性能更好。9.2 安全与隐私“大脑活动记录”比普通社交信息更私密。它可能涉及用户的职业学习方向、阅读偏好、情绪状态和思考习惯。因此在设计接口时要把用户权限隔离放在优先级最高的位置。最核心的规则是永远不要通过前端传入的 userId 直接查询数据除非你的业务模型是完全公开的。生产环境应该从当前登录态解析用户身份或者在接口层做资源归属校验确保用户只能操作自己的数据。简单示例if (!activity.getUserId().equals(currentUserId)) { throw new BusinessException(无权操作该记录); }另外建议在 activities 表增加is_public字段用于控制单条记录是否对外可见。默认应为不公开用户主动勾选后才分享。删除操作建议做软删除也就是增加deleted_at字段而不是直接把记录物理删除。9.3 性能优化当用户行为数据达到十万级以上时以下几个环节需要优先优化。统计数据缓存。用户每次刷新首页都可能触发统计接口。活动数、总时长这类指标可以用 Redis 缓存或数据库表中的冗余统计字段活动创建成功后异步更新缓存避免每次都全表聚合。时间线分页。目前findByUserIdOrderByCreatedAtDesc会一次性返回全部记录这在数据量小的时候没有影响数据量大了以后必须改为分页查询例如 Spring Data JPA 的Pageable。成就计算异步化。如果成就规则很多每次创建活动都同步计算会很耗时。可以使用 SpringAsync把成就判断放到异步线程池中执行并通过事件机制解耦。9.4 产品层面建议降低记录成本是留存关键。无论技术做得多完善如果用户记录一条想法需要输入十几个字段产品就很难留住人。Edgi 类产品的 MVP 应该让用户在 10 秒内完成一次记录例如只填标题和类型其余字段都设为可选。多做周期回顾。周末给用户推送“本周你投入了多少时间”月底生成月度类型分布。这种回顾既是数据统计功能也是产品增长功能。用户为了看到更完整的回顾会更愿意持续记录。考虑导入能力。如果产品能接入 Instapaper、Pocket、微信读书、B站收藏等平台的数据用户的记录难度会大幅下降。这类导入能力在初期不用做全优先接入一到两个高频内容源即可。10. 总结与下一步学习方向本文从 Edgi 那句“Letterboxd or Strava for your brain”出发分析了这类“大脑活动记录”产品的设计思路并给出了一套基于 Spring Boot 和 MySQL 的最小后端实现。学习这类项目不能只停留在会写 CRUD。我更建议你把下面几个能力作为下一阶段的学习方向学习如何用 Spring Security 和 JWT 完善认证授权解决数据越权问题。学习如何把统计指标改为基于 Redis 的缓存方案理解缓存一致性问题。学习如何把成就规则改造成配置化引擎体会“规则与代码分离”的设计思路。学习