为什么:联调 10-19 开始,前端照 api-phase1.md §1.9 写会 404,需要先把这 5 个补齐。 契约先行(12-上线检查清单 §一):先重写 api-phase1.md §1.9 —— 把 commerce 三条移出本节标"已实现", 补齐 5 个接口的请求/响应字段、错误码、分页口径、幂等矩阵,并新增 200007 CREATOR_NOT_ACTIVE。 - GET /app/config(匿名):硬编码白名单 6 键 + 契约默认值兜底,绝不整表下发 - GET /app/notices(匿名):ops_notice 只读分页(status/target/发布与过期时间),无数据返回空列表 - POST /app/creator/apply:幂等,防重靠 DB 唯一索引 uk_creator_user,并发冲突读回既有记录 - GET /app/creator/works:非生效创作者报 200007 + 分状态提示,不返回空数组冒充成功 - GET /app/creator/stats:与后台 07 看板同口径(字段/notes 逐条一致),null + notes 语义保留 - 冻结模块零改动:module-creator 仅新增 CreatorAppService/两个 VO 与 1 个公开方法 pageMyDramasOf, 既有 pageMyDramas 一行未动;module-system 仅新增 OpsNotice 实体与 Mapper - Swagger 中文注解按 S1 规范补齐;白名单与 /app/notices 加进安全白名单 - 实测:mvn test 98 条全过(app-api 41→56);package 通过;端到端五接口全部符合预期 (重复 apply 同 id + created:false、pending 看 works/stats 报 200007、approved 后正常)
FruitShorts 短剧服务(后端)
短剧服务的服务端,Spring Boot + PostgreSQL,对外两个入口:
管理后台 /admin/**(8080)与客户端 /app/**(8081)。
| 文档 | 内容 |
|---|---|
| docs/prd-phase1.md | 第一阶段 PRD:范围、验收口径、里程碑 |
| docs/backend-design-phase1.md | 后端设计:1 万并发观看的容量推导、成本、部署 |
| docs/api-phase1.md | 接口清单:后台管理 + 客户端,标注已实现 / 下一批 |
| docs/architecture-design.md | 早期架构设计文档 |
| 项 | 版本 |
|---|---|
| 运行时 | JDK 21(LTS),编译目标 Java 17 |
| 框架 | Spring Boot 3.2.5、Spring Security 6、MyBatis-Plus 3.5.7 |
| 数据库 | PostgreSQL 18 |
| 构建 | Maven 3.10 多模块 |
目录结构
FruitServer/
├── fruitshorts-common/ 统一响应、分页、错误码、业务异常
├── fruitshorts-framework/ JWT 鉴权(管理端/客户端双密钥)、权限校验、全局异常、分区维护、MyBatis-Plus、CORS
├── fruitshorts-module-system/ 管理员、角色、权限、菜单、角色分类、配置、操作日志
├── fruitshorts-module-content/ 短剧、剧集、题材、轮播、审核发布
├── fruitshorts-module-media/ 分片上传、媒资、播放地址签名、转码任务与多码率产物
├── fruitshorts-module-user/ C 端账号:验证码、注册登录、会话、资料、注销、风控
├── fruitshorts-module-playback/ 播放票据、观看进度与流水、收藏、下载授权
├── fruitshorts-admin-api/ 启动模块与 /admin/** 控制器(8080)
├── fruitshorts-app-api/ 启动模块与 /app/** 控制器(8081)
├── sql/ 建表、初始化数据、分区维护(见下)
└── docs/ PRD、后端设计、接口清单
开发环境
本机环境由 Homebrew 统一管理(版本以本机实际安装为准):
| 组件 | 版本 | 说明 |
|---|---|---|
| Homebrew | 7.0.9 | 远端指向清华镜像,见文末常见问题 |
| JDK | OpenJDK 21.0.12.1 | brew install openjdk@21,JAVA_HOME 已写入 ~/.zshrc |
| Maven | 3.10.0 | brew install maven,命令为 /opt/homebrew/bin/mvn |
| PostgreSQL | 18.6 | brew install postgresql@18,brew services start postgresql@18 启动 |
| ffmpeg | 9.0.2(Homebrew 当前稳定版) | brew install ffmpeg;只有转码 worker 需要,两个在线入口(8080/8081)不依赖它 |
postgresql@18是 keg-only,命令行工具在/opt/homebrew/opt/postgresql@18/bin, 用之前把它加到 PATH:export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"。
java -version # openjdk 21.0.12.1
mvn -v # Maven 3.10.0 / Java 21.0.12.1
ffmpeg -version # 转码 worker 用;没装也能跑在线服务,只是转码任务会一直排队
ffmpeg 只被
fruitshorts-transcode-worker使用:backend-design-phase1.md§8.5 要求转码与在线服务隔离, 所以 app-api / admin-api 里fruitshorts.media.transcode.worker-enabled默认 false,转码要单独起 worker 进程 (java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar)。
说明:
- 工程编译目标为 Java 17(
maven.compiler.release=17),在 JDK 21 上构建与运行。 - 父 POM 显式声明了注解处理器(
<proc>full</proc>+annotationProcessorPaths): JDK 23 起不再默认扫描 classpath 上的处理器,缺这段配置 Lombok 会失效。 - 旧的 JDK 18 仍留在
/Library/Java/JavaVirtualMachines/jdk-18.0.2.jdk,删除需要 sudo, 不影响使用(java_home与JAVA_HOME都已指向 21)。
快速开始
服务有两个入口,可以只启动其中一个:
| 服务 | 模块 | 端口 | 面向 |
|---|---|---|---|
| 管理端 | fruitshorts-admin-api |
8080 | Vue 后台 |
| 客户端 | fruitshorts-app-api |
8081 | Flutter 客户端 |
1. 初始化数据库
export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"
cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/schema.sql
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/data.sql
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/schema-phase1.sql
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/partition-maintenance.sql
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/data-phase1.sql
| 脚本 | 作用 |
|---|---|
schema.sql / data.sql |
系统域与内容域的基础表与初始化数据 |
schema-phase1.sql |
第一阶段增量表:用户、播放记录、互动、交易、创作者、转码、运营 |
partition-maintenance.sql |
分区函数 + 把高写入表转成分区 + 预建未来 3 个月分区 |
data-phase1.sql |
新增权限点、超管授权、占位商品 |
四个脚本都幂等可重复执行,顺序不要颠倒:分区转换要求目标表是空的。
数据库名 fruitshorts,角色 fruitshorts(密码 fruitshorts)。
首次搭建环境时先建角色与库:
psql -d postgres -c "CREATE ROLE fruitshorts LOGIN PASSWORD 'fruitshorts';"
psql -d postgres -c "CREATE DATABASE fruitshorts OWNER fruitshorts ENCODING 'UTF8';"
连接信息在
fruitshorts-admin-api/src/main/resources/application.yml,可用环境变量
DB_USER / DB_PASSWORD / JWT_ADMIN_SECRET 覆盖。
关于 PostgreSQL 的两点说明
- 自增列用
GENERATED BY DEFAULT AS IDENTITY;sql/data.sql里用了显式 ID, 脚本末尾会把序列setval到当前最大值,新增数据不会主键冲突。 - PostgreSQL 没有
ON UPDATE CURRENT_TIMESTAMP,updated_at由set_updated_at()触发器统一维护(见sql/schema.sql末尾),实体侧仍不参与更新该字段。 - 轮播图的展示位置列名叫
slot(position在 PostgreSQL 里是关键字), 实体用@TableField("slot")映射,对外接口字段仍是position,前端无需改动。
大表分区与保留策略
为什么要一开始就分区:watch_history(观看流水)、playback_ticket(播放票据)、
order_event(订单流水)都是「每次行为写一行」的表。按 1 万并发观看估算,观看流水约
300 万行/天、播放票据约 360 万行/天,一年就是十亿级。这类表后补分区的代价很高
(要在维护窗口里整表重建),所以第一阶段就按 watched_at / created_at 做按月声明式分区。
光建表没用:声明式分区不会自动长分区,只建 default 分区的话数据会全堆进 default, 分区等于白做。所以有两层保障:
sql/partition-maintenance.sql里的ensure_monthly_partitions(table, from, months)负责按月建分区,执行脚本时会建好当前月 + 未来 3 个月;fruitshorts-framework的PartitionMaintainer在启动时和每天 03:17 各调一次, 跨月时即使服务停机一周也不会漏建。
验证分区是否真的生效(应落在 watch_history_2026xx 而不是 _default):
psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -c \
"select tableoid::regclass as 分区, count(*) from watch_history group by 1;"
保留策略默认只建不删。删数据不可逆,第一阶段先解决"写不爆",等保留期与合规确认后
再把 fruitshorts.partition.retention-enabled 打开;各表保留月数写在 PartitionMaintainer
的 TABLES 常量里(观看流水 24 个月、播放票据 6 个月、订单流水 36 个月)。手工清理:
SELECT drop_partitions_before('watch_history', CURRENT_DATE - INTERVAL '24 months');
分区表有两条 PostgreSQL 限制要注意:唯一索引必须包含分区键,所以 playback_ticket
的唯一索引是 (ticket_no, created_at)、主键是 (id, created_at)。
coin_ledger 故意不分区 —— 它靠 idempotent_key 全局唯一保证不重复发币,
这个唯一性比分区更重要。
2. 构建
cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer
mvn -DskipTests package
3. 启动
cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer
java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar
java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar
启动后:服务地址 http://localhost:8080,接口文档 http://localhost:8080/doc.html。
关键配置
| 配置项 | 默认值 | 说明 |
|---|---|---|
fruitshorts.jwt.admin-secret |
开发默认值 | 管理端签名密钥,生产必须用 JWT_ADMIN_SECRET 注入 |
fruitshorts.jwt.app-secret |
开发默认值 | 客户端签名密钥,与管理端物理隔离,两边 token 不能互换 |
fruitshorts.jwt.app-access-ttl-minutes |
120 | 客户端 access token 有效期 |
fruitshorts.jwt.app-refresh-ttl-days |
30 | refresh token 有效期,每次刷新轮换 |
fruitshorts.media.signature-secret |
开发默认值 | 播放地址签名密钥,生产必须注入 |
fruitshorts.media.signature-ttl-minutes |
15 | 播放地址有效期 |
fruitshorts.media.signature-required |
false |
生产必须打开;打开后 /files/episode/** 无有效签名返回 403 |
fruitshorts.otp.sender |
log |
验证码通道,开发打日志;接入短信/邮件时实现 OtpSender 并改这里 |
fruitshorts.partition.enabled |
true |
是否自动维护分区 |
fruitshorts.partition.months-ahead |
3 | 预建未来几个月分区 |
fruitshorts.partition.retention-enabled |
false |
是否自动清理过期分区(默认只建不删) |
开发阶段签名校验默认关闭,是为了不打断正在联调的客户端。上线前必须打开, 否则片源地址可以被任意复用与盗链。
第一阶段实现进度
| 模块 | 状态 | 说明 |
|---|---|---|
| 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护 |
| 02 用户账号与登录 | 已实现 | 手机号/邮箱注册登录、验证码反刷、令牌轮换、资料、注销、封禁即时生效 |
| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;补内容分级、可播区域、版权窗口、标签字段 |
| 04 播放器与播放控制 | 部分 | 播放票据 + 签名地址 + 进度已实现;多码率片源待转码 worker |
| 05 记录、收藏与下载 | 部分 | 进度、观看流水、收藏已实现;离线下载授权待做 |
| 06 评论、点赞与分享 | 待做 | 表已建,接口与实现待补 |
| 07 创作者中心 | 待做 | 表已建;转码任务与多码率产物记账已实现 |
| 08 平台运营后台 | 部分 | App 用户管理已实现;内容治理、订单、看板待做 |
| 09 分集收费与支付 | 待做 | 表已建;播放入口的权益判定端口已预留,接上即生效 |
| 10 质量、数据与上线 | 待做 | 压测、埋点、对账待做 |
默认账号
密码均为 admin123。
| 账号 | 角色 | 权限范围 |
|---|---|---|
| admin | 超级管理员 | 全部 14 项权限 |
| content.editor01 | 内容运营 | 短剧、剧集的增删改与封面上传 |
| content.reviewer01 | 审核专员 | 查看内容、审核发布、数据报表 |
| data.analyst01 | 数据分析师 | 查看短剧列表与数据报表 |
接口一览
认证 /admin/auth
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /login |
登录,返回 token 与权限码 |
| GET | /menus |
当前用户菜单路由树(前端动态路由) |
| GET | /profile |
当前登录人信息 |
| POST | /logout |
登出(JWT 无状态,前端清除 token 即可) |
| POST | /password |
修改自己的密码 |
后台管理
| 模块 | 接口 |
|---|---|
| 管理员 | GET /admin/admin-users/page、POST /admin/admin-users、PUT|DELETE /admin/admin-users/{id}、PUT /{id}/status、PUT /{id}/password |
| 角色 | GET /admin/roles/page、GET /admin/roles/list、POST /admin/roles、PUT|DELETE /admin/roles/{id}、GET|PUT /admin/roles/{id}/permissions |
| 角色分类 | GET|POST /admin/role-categories、PUT|DELETE /admin/role-categories/{id} |
| 权限点 | GET /admin/permissions(按分组返回,供权限配置页使用) |
| 菜单 | GET /admin/menus/tree、POST /admin/menus、PUT|DELETE /admin/menus/{id} |
| 系统配置 | GET|PUT /admin/configs |
| 操作日志 | GET /admin/operation-logs/page(按操作人、模块、动作、结果、时间范围筛选)、GET /admin/operation-logs/{id} |
| 字典 | GET /admin/dicts/drama-category、GET /admin/dicts/content-status |
内容管理
| 模块 | 接口 |
|---|---|
| 短剧 | GET /admin/dramas/page、GET /admin/dramas/{id}、POST /admin/dramas、PUT|DELETE /admin/dramas/{id} |
| 审核发布 | POST /admin/dramas/{id}/submit、/approve、/reject、/publish、/offline |
| 审核记录 | GET /admin/dramas/{id}/review-records |
| 剧集 | GET /admin/episodes?dramaId=、POST /admin/episodes、PUT|DELETE /admin/episodes/{id}、PUT /admin/episodes/sort |
| 轮播图 | GET /admin/banners/page、POST /admin/banners、PUT|DELETE /admin/banners/{id}、PUT /admin/banners/{id}/status、PUT /admin/banners/sort |
| 短剧类型 | GET|POST /admin/drama-categories、PUT|DELETE /admin/drama-categories/{id}(权限沿用 drama:*) |
媒资 /admin/media
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /upload/init |
建立分片会话,返回 uploadId、chunkSize、totalChunks |
| POST | /upload/chunk |
上传单个分片(multipart:uploadId、index、file) |
| POST | /upload/complete |
合并分片,返回媒资(含可访问的 url) |
| GET | /{id} |
查询媒资 |
媒资存储(本地)
当前没有对象存储,文件直接落在本机磁盘:
| 项 | 默认值 | 说明 |
|---|---|---|
| 存储目录 | ./data/uploads |
配置项 fruitshorts.media.root-dir,按 业务类型/年月/uuid.ext 分目录 |
| 访问前缀 | /files/** |
配置项 fruitshorts.media.public-prefix,映射为静态资源 |
| 分片大小 | 5MB | fruitshorts.media.chunk-size,单文件上限 2GB |
| 请求上限 | 20MB | spring.servlet.multipart,只需容纳一个分片 |
要点:
/files/**在SecurityConfig里是公开路径,因为<img>/<video>无法携带请求头。- 上传权限:封面用
cover:upload,视频用episode:upload。 data/是运行时产物,不需要提交到版本库。- 换成 OSS/S3 时只需替换
MediaStorageService(分片落盘、合并、生成访问地址三处),MediaService与前端接口都不用动。
统一约定
- 响应体
{ code, message, data },成功code=0。 - 分页请求
pageIndex(从 1 开始)、pageSize,响应{ total, list }。 - 鉴权头
Authorization: Bearer <token>;未登录code=401,无权限code=403。 - 错误码见架构文档附录 A;业务错误如
100000账号密码错误、200003状态不允许该操作。
客户端接口 /app/**
面向 Flutter 客户端,只暴露已上线内容,不含审核状态、操作日志等管理端字段。 完整接口清单(含请求/响应示例与错误码)见 docs/api-phase1.md。
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /app/auth/otp |
发送验证码(REGISTER/LOGIN/RESET_PWD/BIND) |
| POST | /app/auth/register |
手机号/邮箱注册,返回令牌 |
| POST | /app/auth/login |
验证码或密码登录 |
| POST | /app/auth/refresh |
刷新令牌(refresh token 每次轮换) |
| POST | /app/auth/logout |
登出,allDevices=true 吊销全部会话 |
| GET/PUT | /app/me |
我的资料 |
| GET | /app/me/devices |
登录设备 |
| GET | /app/me/export |
数据导出(隐私合规) |
| POST | /app/me/deactivate /cancel |
申请/撤销注销(15 天冷静期) |
| GET | /app/home/feed |
首页聚合:轮播(HOME_TOP)+ 分区(热门 / 新剧 / 各分类) |
| GET | /app/discover/feed |
发现页聚合:轮播(DISCOVER)+ 分类分区 |
| GET | /app/categories |
分类列表 |
| GET | /app/dramas/{id} |
详情,含剧集列表,一次请求即可渲染详情页 |
| GET | /app/episodes/{id}/play |
播放票据:服务端判定权益后签发短时效签名地址 |
| POST | /app/watch/progress |
上报观看进度(建议每 10s 一次) |
| GET | /app/watch/progress?dramaId= |
某剧续看位置 |
| GET | /app/watch/recent |
最近观看 |
| DELETE | /app/watch/progress[/{dramaId}] |
删除 / 清空记录 |
| POST | /app/favorites/toggle |
收藏切换 |
| GET | /app/favorites |
收藏列表(卡片结构与首页一致) |
约定:
- 只返回
status = published的短剧与剧集,未上线按不存在处理。 - 图片与视频返回相对路径(
/files/...),客户端拼上服务地址即可; 播放地址会带?e=&s=签名参数,客户端原样使用、不要自己拼接。 - 播放票据四种结果:
FREE、ENTITLED(已购权益)、COIN_REQUIRED(返回unlockCoin)、NO_MEDIA(已上线但还没传视频或未转码完成)。 - 鉴权边界:只有浏览类接口(
/app/auth/**、/app/home|discover|categories|dramas、/app/episodes/*/play)允许匿名,其余/app/**一律需要客户端 token; 管理端 token 打到客户端接口会被拒(两边密钥不同)。 - 需要登录的接口建议带
X-Device-Id,用于下载授权、并发播放限制与风控。
性能(本机实测,预热后 30 次采样,目标 50ms):
| 接口 | 平均 | P95 | 最大 | 响应体 |
|---|---|---|---|---|
/app/home/feed |
13.6ms | 20.3ms | 23.4ms | 3.9KB |
/app/discover/feed |
7.3ms | 8.1ms | 8.8ms | 1.7KB |
/app/categories |
2.0ms | 2.1ms | 3.4ms | 0.2KB |
/app/dramas/{id} |
2.8ms | 3.1ms | 3.5ms | 0.7KB |
/app/episodes/{id}/play |
2.2ms | 2.6ms | 3.3ms | 0.2KB |
SlowRequestLogger 会对超过 50ms 的 /app/** 请求打 WARN 日志,
内容量增长或索引失效时能第一时间发现;首页聚合是查询最多的一档(分类 + 热门 + 新剧 + 各分类列表),
如果将来分类变多,优先给它加缓存。
权限模型
- 权限码随 JWT 下发(如
drama:create、banner:list),控制器用@PreAuthorize("@ss.hasPermission('drama:create')")校验。 - 菜单树由权限码派生:角色拥有哪些权限,就可见哪些菜单。
- 内容状态机:
draft → reviewing → approved → published → offline。 草稿不能直接发布,已上线的短剧需先下线才能删除;每次流转写入审核记录与操作日志。 - 鉴权实时读库(
PermissionChecker通过PermissionProvider查当前用户的权限), 后台改了角色权限或菜单,前端刷新一下即可生效,不用重新登录。 前端在每次进入应用时会重新拉取GET /admin/auth/menus与GET /admin/auth/profile。
操作日志
后台写操作由切面自动记录,不需要在每个接口里手写,在控制器方法上加注解即可:
@OperationLog(module = "内容管理", action = "新增短剧类型")
public Result<Long> create(@Valid @RequestBody DramaCategorySaveReqVO req) { ... }
每次调用记录:操作人、操作时间、模块、动作、请求方式与地址、请求参数、请求体、 响应体、成功/失败、错误信息、耗时、IP。
约定:
- 只记写操作(POST/PUT/DELETE),GET 查询不入库,避免刷屏。
- 请求体与响应体超过 4000 字符会截断;密码、密钥、token 等字段自动替换成
***。 - 业务异常记
FAIL并带错误信息;@PreAuthorize拦下的越权访问由AccessDeniedRecorder单独记一条(模块=安全,动作=越权访问被拒绝)。 - 日志写入失败不影响业务(切面内兜底 try/catch)。
- 查看入口:后台管理 → 操作日志,权限码
log:list,默认只给超级管理员。
前端对接
管理后台 ../ReelShortWeb 已完成全部对接,本地演示数据已删除:
| 页面 | 使用接口 |
|---|---|
| 登录 / 左侧菜单 | POST /admin/auth/login、GET /admin/auth/menus |
| 工作台 | GET /admin/dramas/page(统计与最近更新) |
| 短剧管理 | /admin/dramas/**、/admin/dicts/drama-category、审核发布动作 |
| 剧集管理 | /admin/episodes/**、PUT /admin/episodes/sort |
| 角色权限配置 | /admin/roles/**、GET /admin/permissions、GET /admin/role-categories |
| 管理员用户列表 | /admin/admin-users/** |
| 菜单管理 | GET /admin/menus/tree + 增删改 |
| 角色分类 | /admin/role-categories/** |
| 系统设置 | GET|PUT /admin/configs |
前端侧改动:
.env.base关闭 mock(VITE_USE_MOCK=false);vite.config.mts增加/admin代理到http://127.0.0.1:8080;src/api/admin/*.ts封装全部管理端接口;- 请求头自动携带
Authorization: Bearer <token>; src/stores/rbac.js的can(code)改用登录返回的权限码, 原src/data/、src/permissions/下的演示数据已删除;- 表格状态列改用接口返回的
status/statusText, 大数值用src/utils/format.js的formatCount格式化。
尚未实现
评论点赞分享、订单与钱包权益(IAP / Google Play Billing 票据校验、对账、退款回收)、
创作者中心、内容治理与经营看板、转码 worker(消费 transcode_task,产出多码率 HLS)、
Redis 缓存与限流、离线下载授权、埋点与数据看板。
这些模块的表结构、接口契约、状态机与权限点都已经冻结,见 docs/api-phase1.md 里标注「下一批」的部分 —— 接下来是照契约填代码, 不会再有结构性改动。
常见问题
编译报「找不到符号 getXxx / 无法推断类型参数」
Lombok 注解处理器没跑起来。确认是在 JDK 21 下构建(mvn -v 应显示 Java 21),
父 POM 已声明 annotationProcessorPaths,若仍报错先 mvn clean。
8080 端口被占用
lsof -tiTCP:8080 -sTCP:LISTEN | xargs kill
brew 连不上 GitHub
本机访问 github.com 不通,brew 仓库已改用清华镜像:
git -C /opt/homebrew remote set-url origin https://mirrors.tuna.tsinghua.edu.cn/git/homebrew/brew.git
Bottle 走 USTC 镜像(环境变量 HOMEBREW_BOTTLE_DOMAIN)。
要换回官方源,把 origin 改回 https://github.com/Homebrew/brew 即可。
临时切换 JDK
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
Homebrew 的 Maven 在 JAVA_HOME 未设置时会回退到它自带的 JDK(当前为 27),
父 POM 的注解处理器配置保证这种情况下也能编译通过。