# yunmo_java **Repository Path**: cdhong/yunmo_java ## Basic Information - **Project Name**: yunmo_java - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-23 - **Last Updated**: 2026-10-29 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # yunmo-java 云墨(移动互联实训项目)后端的 Spring Boot 实现,与 Flask 版 `yunmo_flask` 接口 1:1 对齐, 前端 `yunmo_vue` 无需任何改动即可切换。 > **注意**:2026-10-23 做过一次数据模型精简(29 张表 → 23 张表), > **表结构已与 Flask 版分叉**,但接口契约完全不变(前端零改动)。 > 两版无法再直接共用一个库同时运行,详见 [docs/db/MIGRATION_V2.md](src/main/resources/docs/db/MIGRATION_V2.md)。 ## 技术栈 | 组件 | 版本 | 用途 | | --- | --- | --- | | Spring Boot | 3.5.0 | Web / 校验 / 邮件 / Redis | | MyBatis-Plus | 3.5.5 | 数据访问(单表用条件构造器,复杂 SQL 在 XML) | | MySQL | 8.x | 业务库 `yunmo` | | Redis | — | 登录态 `login_token:*`、验证码 `email_code:*` | | Lombok | 随 Boot | 构造器注入(`@RequiredArgsConstructor`) | | springdoc-openapi | 2.8.13 | 接口文档引擎(OpenAPI 3.1) | | knife4j(next fork) | 5.7.6 | 文档 UI,访问 `/doc.html` | | JDK | 17 | 编译与运行 | ## 前置条件 本项目**不是独立可运行的仓库**,需与另外两个目录同级放置: ``` 移动互联/ ├── yunmo_flask/ # 上传目录来源:uploads/、config/ai_image.json ├── yunmo_vue/ # 画作静态资源:public/images/art └── yunmo_java/ # 本仓库 ``` 同时需要本地已启动 MySQL(库名 `yunmo`)与 Redis。数据库账号默认 `root/root`, 可用环境变量 `YUNMO_DB_PASSWORD` 覆盖。 ## 快速开始 ```bash # 编译 mvn -o clean package # 运行(默认 5000 端口) mvn -o spring-boot:run ``` 启动后访问 `http://127.0.0.1:5000/api/auth/check`,未登录返回 `{"code":401,"msg":"请先登录","data":null}` 即为正常。 ### Windows 开发环境 本机 JDK 17 与 Maven 不在系统 PATH 上,需显式指定: ```bash export JAVA_HOME=/d/devtools/Java/jdk-17 /d/devtools/apache-maven-3.8.6/bin/mvn.cmd -o test ``` 或直接用 `scripts/run-dev.sh`(已处理好 `JAVA_HOME`)。 ## 目录结构(三层架构) ``` src/main/java/com/yunmo/ ├── controller/ 表现层,9 个 Controller(8 业务 + 容器错误兜底 RestErrorController) ├── dto/ 请求体对象,50 个 record(字段名 = 前端 JSON key,带 Bean Validation) ├── service/ 业务层,12 个 XxxService 类(无接口层,每个类只有一个实现) │ └── support/ 基础设施组件,7 个:StorageService / MailService / OllamaClient / │ AiAuditQueue / AiImageSettings / TokenService / CodeService ├── mapper/ 持久层,25 个 Mapper ├── entity/ 实体,23 个(与表一一对应,字段零偏差) ├── common/ 通用支撑,按职责分四个子包: │ ├── exception/ 异常出口:BizException + GlobalExceptionHandler │ ├── result/ 结果契约:Result(唯一响应体)/ Msg(成功文案)/ ErrorCode │ ├── security/ Spring Security 组件:Bearer 过滤器 / 恒 200 兜底(SecurityConfig 在 config/) │ └── util/ 纯工具:Vals ├── config/ 配置类:Web / Jackson └── event/ Spring 事件(发布后异步审核) src/main/resources/ ├── application.properties └── mapper/ 20 个 Mapper XML,共 81 条 SQL ``` `common` 根目录不放任何类,只作为四个子包的父级;`service` 根目录只放业务接口, 支撑组件全部下沉到 `service/support`,这样一进包就能看清业务边界。 四个子包的分包口径是**职责**,不是名字,别按"类名叫什么就往哪儿塞": - `exception` —— 异常的抛出(BizException)与统一接住(GlobalExceptionHandler)同包, 改错误响应格式时两处一起改,不用跨包找; - `result` —— 只管响应体信封与错误码常量,**不放异常**; - `security` —— 只管"怎么认出请求是谁":Bearer 过滤器解析 token、SecurityContext 承载当前用户(`@AuthenticationPrincipal Long userId` 直接注入 Controller 参数)、 401/403 的恒 200 兜底;它不碰 Redis 凭证存取,那是 `service/support` 的 TokenService / CodeService; - `util` —— 无状态、无 Spring 注入的纯函数(Vals)。 判断一个新类往哪儿放:**依赖外部资源(Redis / 磁盘 / 邮件 / LLM)→ `service/support`; Spring Security 过滤器与兜底 → `common/security`,SecurityConfig → `config`;纯函数 → `common/util`; Controller → `controller`;请求体对象 → `dto`。** ## 分层约定(改代码前先看这一节) ### 分层约定:Service 处理,Controller 调用 响应体只有一个类型:`Result`。它只在 Controller 与 `GlobalExceptionHandler` 出现。 | 角色 | 职责 | 写法 | |---|---|---| | Service | 干活:查库、算业务、改状态 | 返回数据本体或 `void`;失败 `throw BizException` | | Controller | 调用 + 挑文案 + 包响应 | `Result.ok(...)` | | `BizException` | 失败路径的唯一出口 | 由 `GlobalExceptionHandler` 统一包装成 `Result.error` | ```java // 查询类:文案走默认「获取成功」 return Result.ok(userService.getInfo(userId)); // 写操作:文案从 Msg 挑,不写字面量 userService.deleteDraft(userId, draftId); return Result.ok(Msg.DRAFT_DELETED); // 写操作要回传新状态 return Result.ok(Msg.QUIZ_ARCHIVED, quizService.saveGameProfile(userId, req)); ``` `Result` 的四个工厂:`ok(data)` / `ok(msg)` / `ok(msg, data)` / `error(code, msg)`。 三条理由,别走回头路: 1. **失败必须走异常**:`return Result.error(...)` 不触发 `@Transactional` 回滚, 一旦写库后这么返回就留脏数据。 2. **文案属于表现层**:Service 不关心"给用户看什么话",只回"现在是什么状态"。 3. **一个类型就够**:曾经同时存在 `Result`(带 code)与 `Outcome`(不带 code), 两个几乎一样的信封互相转换,纯属自找麻烦。 **多分支文案怎么处理**:当同一接口有两条成功路径、话术不同时,Service 在返回的 data 里 带一个判别字段,Controller 按字段选文案。判别字段是"状态描述符",不是给前端的业务数据, void 接口的 Controller 可以只看不用(响应 data 仍为 null,契约不变)。 | 接口 | Service 回传 | Controller 挑文案 | |---|---|---| | 发帖 / 草稿发布 / 评论 | `pending_review` | 待审 → "发布成功,正在审核中" | | 点赞 / 收藏 / 点亮 / 置顶 | `liked` / `favorited` / `lit` / `is_top` | 取反时给"已取消…" | | 关注 / 取消关注 / 重提审核 | `changed` | false → "已关注" / "未关注" / "已在审核中" | | 编辑帖子 | `status` | 2 下架 / 3 待审 / 其他 已发布 | | 新增或更新(地方志、风物卡、公告) | `created` / `status` / data 为 null | 区分"已录入"与"已更新" | 例外只有一个:`AdminService.createNotice` 这类"改完才知道状态"的,直接读回写入后的行, 不要在前端参数上重算一遍默认值。 ### 成功文案统一走 Msg 面向用户的提示语集中在 `com.yunmo.common.result.Msg`,Controller 里**不再出现中文字面量**。 ```java public static final String DRAFT_DELETED = "草稿已删除"; public static final Toggle LIKE = new Toggle("已点赞", "已取消点赞"); // 用 Toggle 读判别字段,顺带统一了判空口径 return Result.ok(Msg.LIKE.pick(data, "liked"), data); // 调用方自己算好布尔值时 return Result.ok(Msg.WORK_REVIEW.of(status == 1)); ``` 规矩三条: 1. **新增文案先查重**。那里已有同义表达就复用(「已通过」同时服务于作品审核与评论审核), 别为了"读起来顺"再抄一份,否则统一改文案的价值就没了。 2. **成对文案用 `Toggle`**,不要写 `Boolean.TRUE.equals(data.get(k)) ? A : B`。 这行以前重复了 10 次以上,且容易漏判空写成 `(Boolean) data.get(k)`,字段缺失就 NPE。 3. **错误文案不进 `Msg`**。`请先登录` / `资源不存在` 这类属于 `ErrorCode` 与异常处理器, 和「成功提示」是两个体系,混在一起会让 `Msg` 失去单一语义。 **测试里仍然写死字面量**,不要改成 `Msg.XXX` 断言 —— 测试的职责是锁定用户可见的字符串, 若跟着常量走,「改文案」和「改断言」会同步发生,等于没测。现有 152 处 `msg` 断言就是这道防线。 ### 鉴权统一走 Spring Security 门禁是**方法级**的,URL 级全放行(`SecurityFilterChain` 里 `anyRequest().permitAll()`)—— 游客态接口占一半以上,三态模型无法用 URL 规则表达。三个门禁写法: | 需求 | 写法 | |---|---| | 强制登录 | `@PreAuthorize("isAuthenticated()")` | | 管理员(role=9) | `@PreAuthorize("hasRole('ADMIN')")` | | 游客态(登录可选) | 无门禁注解,参数用 `@AuthenticationPrincipal Long userId`(未登录注入 null) | 认证链路:`BearerTokenAuthenticationFilter` 读 `Authorization: Bearer` → Redis 查 `login_token:` → 查 user 表构建 Authentication(principal=Long userId, role=9 授予 ROLE_ADMIN)。密码走 `PasswordEncoder` bean(DelegatingPasswordEncoder, `{bcrypt}` 格式)。 三条易踩的坑,改鉴权相关代码前先看: 1. **匿名认证必须保持禁用**(`.anonymous(disable)`):Spring 默认的 AnonymousAuthenticationToken 的 `isAuthenticated()` 返回 true,开着会让全部 登录门禁形同虚设。 2. **方法级拒绝的异常落在 `GlobalExceptionHandler`**,不是 Security 的 AccessDeniedHandler:无认证对象抛 `AuthenticationCredentialsNotFoundException` (→ 401),有认证但权限不足抛 `AccessDeniedException`(→ 403),两个 handler 都在异常处理器里,HTTP 恒 200 契约靠它们维持。 3. **401/403 文案是契约**:「请先登录」「无权限访问后台」,前端 fetch 封装只认 `body.code === 401`,测试 10+ 处断言这些字面量,别改。 ### 请求体必须用 DTO - 一律 `record`,放 `com.yunmo.dto`,**组件名就是前端 JSON key**(Jackson 按组件名绑定)。 历史接口里 camelCase 与 snake_case 混用,照抄原名,不要"顺手统一风格"。 - 只有「单字段、原来就独立判空」的才用 `@NotBlank`;注解的 `message` 必须等于原来那句中文文案。 - **校验顺序被接口契约锁定的,不要用注解**。例如风物卡要求固定按「题名→所在地→省份→图片」 报错,而 Bean Validation 的字段错误顺序不保证,这类必须留在 Service 里顺序判断。 - 类型要接住前端现有传法:原来 `Vals.truthy` / `Boolean.parseBoolean` 处理的用 `Boolean` (Jackson 能同时接 `true` 和 `1`);原来 `Vals.parseIntOr` 的用 `Integer` / `Long`。 - **唯一的例外**:`POST /api/admin/ai/image/config` 的请求体是任意键值对(键集合由 `config/ai_image.json` 决定),DTO 化会静默丢键导致密钥存不进去,故保留 `Map`。 ## 数据访问约定 分工是明确的,不要混着写: - **单表 CRUD、等值/IN/区间条件** → `BaseMapper` + Lambda 条件构造器,写在 Service 里; - **多表 JOIN、动态 ORDER BY、NOT EXISTS、INSERT IGNORE、ON DUPLICATE、 GREATEST/COALESCE 原子增减、聚合统计** → 写进 `resources/mapper/*.xml`, Mapper 接口只留方法签名(无注解)。 XML 里有两个坑: 1. 不等号必须写 `!=`,裸 `<` 会被当成标签起始导致解析失败;确实要比较时写 `<` 或包进 CDATA。 2. `resultType` 返回集合时写元素类型,例如 `List>` 写 `java.util.Map`, 不要写 `java.util.List`。 ## 数据模型(23 张表) 按职责分四类: | 类别 | 表 | 数量 | | --- | --- | --- | | 内容实体 | `tbl_user` `tbl_blog` `tbl_section` `tbl_gallery_work` `tbl_gallery_exhibition` `tbl_ink_element` `tbl_quiz_question` `tbl_quiz_game_item` `tbl_atlas_region` `tbl_atlas_card` `tbl_atlas_route` `tbl_notice` | 12 | | 通用互动 | `tbl_interaction` —— 点赞 / 收藏 / 关注三合一 | 1 | | 通用评论 | `tbl_comment` —— 茶馆评论与丹青坊题跋合一 | 1 | | 用户状态 | `tbl_quiz_profile` `tbl_quiz_wrong` `tbl_notice_read` `tbl_atlas_footprint` `tbl_atlas_note` | 5 | | 流水与日志 | `tbl_quiz_record` `tbl_quiz_answer` `tbl_action_log`(登录 + 浏览合一)`tbl_ai_audit` | 4 | ### 三张合并表的关键约定 **`tbl_interaction`**:`(user_id, target_type, target_id, action)` 唯一。 `target_type ∈ {blog, work, user}`,`action ∈ {like, favorite, follow}`。 **`tbl_comment`**:`target_type ∈ {blog, work}`。SQL 层用别名保住前端字段名—— 茶馆输出 `target_id AS post_id`,题跋输出 `target_id AS work_id`。 **`tbl_action_log`**:`kind ∈ {login, view}`。浏览记录靠 `uk_view_dedupe(kind, target_type, target_id, ident, log_date)` 做「同人同日同篇只记一次」; 登录行的 `ident` 为 NULL,唯一索引不约束 NULL,多次登录互不冲突。 > **改代码时必须遵守**:任何按业务线查询评论或互动,都要带 `target_type` 过滤。 > 两类数据共表,漏过滤会把另一条业务线的数据混进来。这一点在合并过程中实际踩到 > 3 处(后台待办计数、待审列表 total、批量通过评论),都由测试抓出。 合并前后对照、id 偏移规则、回退步骤见 [docs/db/MIGRATION_V2.md](src/main/resources/docs/db/MIGRATION_V2.md)。 ## 已知技术债 - **`tbl_blog.section_id` 冗余**:实测与 `category_id` 在全部数据行上恒等(29/29), 该列无索引、不参与任何 WHERE / JOIN / ORDER BY,真正关联分类表的是 `LEFT JOIN tbl_section s ON s.id = b.category_id`。 实体字段已标记 `@Deprecated`,列表查询不再取该列;但发帖/存草稿仍按前端传参写入, 以维持写入口行为不变。 彻底清理需 `DROP COLUMN` + 调整 `TeahouseService` 三处方法签名 + 前端停止传 `sectionId`。 详情查询因使用 `b.*` 仍会带出该列,前端不读取,仅多一个字段。 - **schema 与 Flask 版分叉**(自 V2 表精简起):两版不能再共用同一个库。 如需回滚到 Flask 版,先执行 `docs/db/rollback_tables_20261023.sql` 重建旧表。 ## 配置说明 全部配置集中在 `src/main/resources/application.properties`。 ### 路径全部可写相对值 配置内的相对路径按**进程工作目录**解析(开发态即项目根:IDE 与 `mvn spring-boot:run` 默认工作目录都是含 `pom.xml` 的目录;jar 部署即启动命令所在目录)。 项目整体搬家无需改配置,部署时若启动目录不在项目根,可写绝对路径或用环境变量覆盖。 ```properties yunmo.upload.dir=../yunmo_flask/uploads yunmo.art.dir=../yunmo_vue/public/images/art yunmo.ai-image.config-file=config/ai_image.json ``` 部署时可用环境变量或启动参数覆盖: | 配置项 | 环境变量 | 启动参数 | | --- | --- | --- | | `yunmo.upload.dir` | `YUNMO_UPLOAD_DIR` | — | | `yunmo.art.dir` | `YUNMO_ART_DIR` | — | | `yunmo.ai-image.config-file` | `YUNMO_AI_IMAGE_CONFIG` | — | | `spring.datasource.password` | `YUNMO_DB_PASSWORD` | — | ### 编码注意事项 Spring Boot 按 ISO-8859-1 读取 properties 文件,**值中出现中文必须写成 `\uXXXX` 转义**。 例如目录别名 `\u79fb\u52a8\u4e92\u8054` 表示「移动互联」: ```properties # 正确 yunmo.some-dir=\u79fb\u52a8\u4e92\u8054 # 错误:读出的是乱码 yunmo.some-dir=移动互联 ``` 写成明文中文会得到乱码;若该值参与路径拼接,上传文件会被写到错误目录。 改配置后务必走完整生命周期(`mvn -o test`,而非 `mvn surefire:test`), 否则 `target/classes` 中的旧配置不会刷新。 注释不受此约束(不参与解析),但本文件历史上部分注释写成 `\uXXXX` 转义形式、 部分写成明文中文,两种混用;用编辑器改注释时按文件里实际的形式匹配,否则替换不到。 ## 接口文档(Knife4j + OpenAPI 3) 启动后访问 `http://127.0.0.1:5000/doc.html`,可看到全部接口并直接发请求调试。 ### 1. 依赖组合(唯一可用) | 依赖 | 版本 | 作用 | | --- | --- | --- | | `org.springdoc:springdoc-openapi-starter-webmvc-ui` | 2.8.13 | 文档引擎,扫描 Controller 生成 OpenAPI 3.1 | | `com.baizhukui:knife4j-openapi3-boot4-spring-boot-starter` | 5.7.6 | knife4j UI(**fork next 版**) | **不要换回官方 `com.github.xiaoymin:knife4j-openapi3-jakarta-spring-boot-starter`**, 原因见坑 1。fork 版在 Maven Central 可正常获取(本机 `settings.xml` 走的是 huaweicloud 镜像)。 ### 2. pom.xml ```xml 17 2.8.13 com.baizhukui knife4j-openapi3-boot4-spring-boot-starter 5.7.6 org.springdoc springdoc-openapi-starter-webmvc-ui org.springdoc springdoc-openapi-starter-webmvc-ui ${springdoc.version} ``` 排除 fork 自带的 springdoc 是刻意的:fork 内置 2.8.9,本项目实测验证过的是 2.8.13, 两者都能跑,统一到一个版本少一个变量。 ### 3. application.properties ```properties # 接口文档(knife4j / OpenAPI 3) # 访问 http://localhost:5000/doc.html knife4j.enable=true knife4j.setting.language=zh_cn springdoc.packages-to-scan=com.yunmo.controller springdoc.api-docs.path=/v3/api-docs springdoc.swagger-ui.tags-sorter=alpha springdoc.swagger-ui.operations-sorter=alpha ``` | 配置项 | 说明 | | --- | --- | | `knife4j.enable` | **必须为 `true`**。它是整组开关,置 false 会连 UI 静态资源映射一起关掉(坑 2) | | `springdoc.packages-to-scan` | 只扫 `com.yunmo.controller`;不配会扫全包,`service`/`entity` 也混进文档 | | `springdoc.api-docs.path` | OpenAPI JSON 地址,knife4j UI 内部按它取数据 | | `tags-sorter` / `operations-sorter` | 文档左侧目录按字母序,接口多了好找 | **不要配 `spring.mvc.static-path-pattern`**——它会限制 Spring Boot 默认的 classpath 静态资源 前缀,把 `/doc.html` 与 `/webjars/**` 一起掐死(坑 3)。 ### 4. 验证清单 ```bash for p in /doc.html /v3/api-docs /v3/api-docs/swagger-config /swagger-ui/index.html; do printf "%-34s %s\n" "$p" "$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' http://127.0.0.1:5000$p)" done ``` 四个都返回 `200` 才算整合成功。期望:`/doc.html` 返回 knife4j UI 页面(含 `knife4j`), `/v3/api-docs` 返回合法的 OpenAPI 3.1 JSON。 > `--noproxy '*'` 不能省:本机装了系统代理,不加会被代理劫持并返回 502 / `upstream connect failed`, > 极易误判成"应用没启动"。 ### 5. 踩坑记录 #### 坑 1:官方 knife4j 4.5.0 在 Spring Boot 3.5 上必崩 Spring Boot 3.5 用的 Spring Framework 6.2 **移除了 `ControllerAdviceBean(Object)` 构造器**。 官方 knife4j 4.5.0 透传的旧 springdoc(2.0.4 / 2.3.0)在生成 `/v3/api-docs` 时会扫描 `@ControllerAdvice`,直接抛 `NoSuchMethodError` → 500。 本项目有 `@RestControllerAdvice`(`GlobalExceptionHandler`),所以**必然**触发,一个都躲不掉。 - ❌ `com.github.xiaoymin:knife4j-openapi3-jakarta-spring-boot-starter:4.5.0` - ✅ `com.baizhukui:knife4j-openapi3-boot4-spring-boot-starter:5.7.6`(knife4j-next fork, 内部 springdoc 升到 2.8.x,专为 Boot 3.4/3.5 兼容) - ✅ 底层 springdoc 单独用 2.8.13(针对 SF 6.2 重编译的版本) #### 坑 2:`knife4j.enable=false` 会把 UI 静态资源一起关掉 关掉"文档增强"能绕开坑 1 的崩溃,但 `knife4j.enable` 是**整组开关**:置 false 时 knife4j 的静态资源映射也不注册,`/doc.html` 变成未知路由,被 `GlobalExceptionHandler` 兜成 `{"code":404,"msg":"资源不存在"}`。 **HTTP 状态是 200,404 在 body 里**——排查时极易误判成"路由没映射"。 #### 坑 3:`spring.mvc.static-path-pattern` 会掐死 `/doc.html` knife4j 的 UI 资源在 jar 内的 `META-INF/resources/doc.html` 与 `META-INF/resources/webjars/knife4j-ui-react/`,靠 Spring Boot **默认的 classpath 静态映射** 经根路径访问。 一旦配了 `spring.mvc.static-path-pattern=/static/uploads/**`,默认映射的前缀就被限死在 `/static/uploads/` 下,根路径的 `/doc.html`、`/webjars/**` 全部 404。 本项目要对外暴露上传目录,改用 `WebConfig.addResourceHandlers` **显式注册** (显式注册不受 `static-path-pattern` 影响): ```java registry.addResourceHandler("/static/uploads/**") .addResourceLocations("file:" + uploadDir.replace('\\', '/') + "/") .setCacheControl(CacheControl.maxAge(12, TimeUnit.HOURS)); ``` #### 坑 4:properties 里的中文与注释 值中的中文必须写 `\uXXXX`(见上文「编码注意事项」)。另外本文件的注释存在 **明文中文**与**`\uXXXX` 转义**两种形式混用(转义是历史写入方式), 用编辑器改这些注释时按文件里实际的形式匹配,否则会替换不到。 #### 坑 5:本地启动必须清掉 `SERVER__PORT` / `SERVER__HOST` 沙箱/容器环境会注入这两个变量,Spring 的松散绑定会让它们**覆盖** `server.port`, 端口变成 10752 之类。启动命令要显式摘掉: ```bash env -u SERVER__PORT -u SERVER__HOST mvn -o spring-boot:run ``` #### 坑 6:`NoHandlerFoundException` 的 handler 不能删 项目用统一响应体(HTTP 恒 200,错误码在 body),未知路由要由 `GlobalExceptionHandler` 兜成 `{"code":404,"msg":"资源不存在"}`。删掉这个 handler,未知路由会被 Spring 容器 兜成 500(HTML 错误页),契约就破了。 ### 6. 已知现象(暂不影响开发):OAS 3.1 校验提示 knife4j 调试时,部分**分页类**接口会提示: ``` 响应体不符合 OAS 3.1 JSON Schema $/data/list type 校验失败 $/data/total type 校验失败 $/data/page type 校验失败 $/data/page_size type 校验失败 ``` 原因:swagger-core 把 `Map` 的值类型 `Object` 误译成「必须是个对象」,生成 ```json "data": { "type": "object", "additionalProperties": { "type": "object" } } ``` 而 `data` 里装的是数组和数字,逐字段核对自然全不匹配。 **这是文档 schema 描述得不准,不是接口返回有问题**——响应体本身完全正确, 不影响联调与开发。影响面仅限 `Result>` / `Result>>` 这类返回动态 Map 的接口; 彻底修需要给这些接口定义强类型响应 DTO,属独立改造。 ### 7. 故障速查 | 症状 | 原因 | 处理 | | --- | --- | --- | | `/v3/api-docs` 返回 500,日志有 `NoSuchMethodError: ControllerAdviceBean` | 用了官方 knife4j 4.5.0 | 换 knife4j-next fork 5.7.6(坑 1) | | `/doc.html` 返回 `{"code":404,"msg":"资源不存在"}` | `knife4j.enable=false` | 改回 `true`(坑 2) | | `/doc.html` 404 但 `/v3/api-docs` 正常 | 配了 `spring.mvc.static-path-pattern` | 删掉该属性(坑 3) | | `/webjars/**` 404 | 同上 | 同上 | | 端口不是 5000 | 环境变量 `SERVER__PORT` 覆盖 | `env -u SERVER__PORT -u SERVER__HOST`(坑 5) | | 未知路由返回 500 HTML 页 | `NoHandlerFoundException` handler 被删 | 恢复 handler(坑 6) | | 文档里混进非接口条目 | 未限定扫描包 | 配 `springdoc.packages-to-scan=com.yunmo.controller` | | curl 本机地址返回 502 / `upstream connect failed` | 被系统代理劫持 | curl 加 `--noproxy '*'` | ## 测试 ```bash mvn -o clean test ``` 共 183 个测试:168 个 MockMvc 集成测试(覆盖全部 8 个 Controller)+ 15 个单元测试 (路径解析、密码哈希)。 注意:`TeahouseService` 等类由 class 改为 interface 这类 ABI 变更,会让 Maven 增量编译 判定测试源码「无需重编」,从而留下陈旧字节码并抛 `IncompatibleClassChangeError`。 遇到此类异常请加 `clean`。 ## 与 Flask 版的兼容性 迁移遵循「逐字复刻、前端零改动」,以下四项必须保持一致: 1. **响应格式**:HTTP 状态恒为 200,业务码放在 body 的 `code` 字段(三层保障: `GlobalExceptionHandler` 两层 + `RestErrorController` 容器级兜底)。 2. **Redis key**:`login_token:`(TTL 7 天)、`email_code:` / `phone_code:` (TTL 300 秒),与 Flask 侧共用同一份数据。 3. **密码哈希**:已切换为 Spring Security 的 `{bcrypt}` 格式 (`PasswordEncoderFactories.createDelegatingPasswordEncoder()`),存量密码已于 2026-10-23 全量重置为 `123456`(备份:`docs/db/tbl_user_bak_before_bcrypt_20261023.sql`); werkzeug scrypt 兼容随之退役,BouncyCastle 依赖已移除。 4. **上传文件名**:头像 `{user_id}_{hex8}{ext}`,普通图 `{timestamp}_{hex8}{ext}`。 ## 出图(文生图) 引擎可在运行时切换,配置存放于 `yunmo_flask/config/ai_image.json`(含 API Key, 已被 `.gitignore` 排除)。比例档位由 `PaintServiceImpl` 中的 `RATIO_OPTIONS` 唯一提供, 经 `/api/ai/paint/status` 下发,前端不自备副本。 提示词默认直接使用用户原文(`rewrite=false`):qwen-image 自带 prompt_extend 已能补全细节,再套一层文本模型只会更慢且改变原意。