Browse Source
**修复一个会让别人克隆后直接编译失败的坏点**: `src/types/index.ts` 的 barrel 早已 `export ... from './application'` 并已入库, 但 `src/types/application.ts` 从未提交(一直是未跟踪状态)。本地因为文件在磁盘上 所以 tsc 通过,**新克隆的仓库会报 TS2307 找不到模块**。 同批补入此前一直未入库的资料与工具: - src/types/application.ts: 投递(Application)领域类型,被 types barrel 引用,属必需文件 - docs/职来后端接口.md: 后端接口契约全文(19645 行),前后端对齐的唯一真相源 - docs/applications-500-diagnosis.md: 投递接口 500 排查记录 - scripts/map_apits.py / scan_wrong.py: 接口映射与错位扫描审计脚本 (scripts/api.ts.bak 为 api.ts 的历史备份副本,不入库)master
5 changed files with 19989 additions and 0 deletions
@ -0,0 +1,196 @@ |
|||
# `/applications` 接口 500 问题诊断报告 |
|||
|
|||
> 前端联调:2026-09-29 |
|||
> 测试账号:`19900009021` / `ZhilaiTest!2026#A` |
|||
> 后端地址:`https://job.youthome.com.cn/api/v1` |
|||
|
|||
--- |
|||
|
|||
## 问题复现 |
|||
|
|||
| 请求 | HTTP | code | message | |
|||
|---|---|---|---| |
|||
| `GET /applications`(带 Bearer token) | **500** | 500 | `internal server error` | |
|||
| `GET /applications?page=0&size=20`(带 token) | **500** | 500 | `internal server error` | |
|||
| `GET /applications`(不带 token) | 401 | 401 | `not authenticated` | |
|||
| `GET /applications/me`(带 token) | 200 | 200 | `ok` ✓ | |
|||
| `POST /applications`(带 token,投递) | 404 | 7001 | `job not found` ✓(正确路由,只是 jobId 不存在) | |
|||
| `GET /applications/me/timeline`(带 token) | 404 | 8002 | `application not found` ✓(正确路由) | |
|||
|
|||
**结论:`GET /applications` 带 token 必现 HTTP 500**,是后端 bug。 |
|||
|
|||
--- |
|||
|
|||
## 根因分析 |
|||
|
|||
### 1. OpenAPI 文档中根本没有定义 `GET /applications` |
|||
|
|||
后端公开的 28 个端点里,applications 模块只有 **5 个**: |
|||
|
|||
| 方法 | 路径 | 用途 | 状态 | |
|||
|---|---|---|---| |
|||
| POST | `/applications` | 提交投递 | ✅ 正常(jobId 不存在时返回 404) | |
|||
| GET | `/applications/me` | 我的投递列表(分页) | ✅ 正常(返回 200 + 分页结构) | |
|||
| GET | `/applications/{id}` | 投递详情 | ✅ 正常(id 不存在返回 404) | |
|||
| PATCH | `/applications/{id}/status` | 企业改投递状态 | — 未测 | |
|||
| POST | `/applications/{id}/withdraw` | 撤回投递 | — 未测 | |
|||
| GET | `/applications/{id}/timeline` | 状态时间线 | ✅ 正常(id 不存在返回 404) | |
|||
|
|||
**没有 `GET /applications` 这条路由。** |
|||
|
|||
### 2. 为什么不是 404 而是 500? |
|||
|
|||
这是 Spring Boot Controller **路由冲突** 导致的。 |
|||
|
|||
后端 Controller 大概率写了: |
|||
|
|||
```java |
|||
@RestController |
|||
@RequestMapping("/api/v1/applications") |
|||
public class ApplicationController { |
|||
|
|||
@GetMapping("/me") // GET /applications/me → 200 ✓ |
|||
public Page<Application> listMine() { ... } |
|||
|
|||
@GetMapping("/{id}") // GET /applications/{id} → 404(id 不存在)✓ |
|||
public Application getById(@PathVariable Long id) { ... } |
|||
|
|||
@PostMapping("") // POST /applications → 404(jobId 不存在)✓ |
|||
public Application create(@RequestBody ...) { ... } |
|||
|
|||
// 但没有写 @GetMapping("")! |
|||
} |
|||
``` |
|||
|
|||
当请求 `GET /applications` 进来时: |
|||
- Spring 路由解析器在候选路由里找匹配 |
|||
- `/applications/me` 需要路径是 `/me`,不匹配 |
|||
- **`/{id}` 匹配成功** —— `id` 被解析为字符串 `"applications"` |
|||
- 但方法签名上 `@PathVariable Long id` 期望 `Long` 类型 |
|||
- Spring 尝试把 `"applications"` 转成 `Long` → **类型转换异常** |
|||
- 全局异常处理器没兜住这个异常 → 返回 HTTP 500 `internal server error` |
|||
|
|||
这是 Spring Boot Controller 常见的路由设计陷阱:**一个 Controller 既有 `/{id}` 又有 `/me`,但忘了写根路径 `@GetMapping("")` 或显式拒绝根路径的 GET 请求**。 |
|||
|
|||
--- |
|||
|
|||
## 修复方案(后端二选一) |
|||
|
|||
### 方案 A:显式拦截并返回 404(推荐,防御性编程) |
|||
|
|||
```java |
|||
@GetMapping("") |
|||
public ResponseEntity<Void> listAll() { |
|||
// applications 没有"列出所有投递"这个语义 |
|||
return ResponseEntity.notFound().build(); |
|||
} |
|||
``` |
|||
|
|||
### 方案 B:确认不需要这个路由,忽略 |
|||
|
|||
如果后端根本不打算提供 `GET /applications`(全量投递列表——企业端才有意义,而且需要按企业 ID 过滤),那 Spring Boot 默认会 404。但 **当前 500 是路由冲突导致的**,需要清理路由或者统一异常处理。 |
|||
|
|||
### 方案 C:按角色分发(如果后端真打算提供) |
|||
|
|||
```java |
|||
@GetMapping("") |
|||
public ResponseEntity<?> listAll(Authentication auth) { |
|||
// STUDENT 应该只查自己的 → 404 引导去 /applications/me |
|||
// COMPANY 可以查自己企业下的投递 → 需要 companyId 过滤 |
|||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( |
|||
"code", 404, "message", "这个路由不存在,请使用 /applications/me 或 /applications/{id}" |
|||
)); |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 其他建议 |
|||
|
|||
### 1. Spring Boot 的 `/{id}` 路由建议加类型约束 |
|||
|
|||
```java |
|||
// 只匹配数字 ID,排除 "/me" 等子路径 |
|||
@GetMapping("/{id:\\d+}") |
|||
public Application getById(@PathVariable Long id) { ... } |
|||
``` |
|||
|
|||
这样 `GET /applications` 和 `GET /applications/me` 不会被 `/{id}` 吃掉。 |
|||
|
|||
### 2. 建议加 Controller 测试 |
|||
|
|||
```java |
|||
@WebMvcTest(ApplicationController.class) |
|||
class ApplicationControllerTest { |
|||
@Test |
|||
void get_root_shouldReturn404() throws Exception { |
|||
mockMvc.perform(get("/api/v1/applications")) |
|||
.andExpect(status().isNotFound()); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 3. 统一异常处理加类型转换兜底 |
|||
|
|||
```java |
|||
@ExceptionHandler(MethodArgumentTypeMismatchException.class) |
|||
public ResponseEntity<Map<String, Object>> handleTypeMismatch(MethodArgumentTypeMismatchException e) { |
|||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( |
|||
"code", 404, |
|||
"message", "资源不存在" |
|||
)); |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 给后端同事的话 |
|||
|
|||
> XX,`GET /applications`(不带 /me)带 token 返回 500 我测出来了,OpenAPI 文档里也没定义这个路由。 |
|||
> |
|||
> 根因是 Spring 路由冲突:Controller 里有 `GET /applications/{id}` 但没有 `GET /applications`,所以 `"applications"` 这个字符串被当成了 `@PathVariable Long id` 传给方法,类型转换失败抛异常,全局异常处理器没兜住就 500 了。 |
|||
> |
|||
> 前端这边**不需要这个路由**——学生端调的是 `/applications/me`(正常),投递调的是 `POST /applications`(正常),详情调的是 `GET /applications/{id}`(正常)。所以最简单的修复就是给 Controller 加个 `@GetMapping("")` 返回 404,或者 `/{id}` 加类型约束 `@GetMapping("/{id:\\d+}")` 排除子路径。 |
|||
> |
|||
> 具体怎么修看你那边代码风格,上面三个方案选一个就行。 |
|||
|
|||
--- |
|||
|
|||
## 附:其他需要后端补的端点清单 |
|||
|
|||
(来自全量联调扫描,前端有调用但后端没端点) |
|||
|
|||
### 🔴 高优先级 |
|||
|
|||
| 前端路径 | 用途 | 前端函数 | |
|||
|---|---|---| |
|||
| `GET /jobs/{id}/score` | 岗位综合评分(8 维度雷达图) | `fetchJobScore` | |
|||
| `GET /jobs/{id}/salary` | 薪资分布数据 | `fetchJobSalary` | |
|||
| `GET /jobs/{id}/reviews` | 面经/岗位评价列表 | `fetchJobReviews` | |
|||
| `GET /jobs/{id}/competition-ranking` | 同岗位竞争力排名 | `fetchRankData` | |
|||
| `GET /profile/me/career-progress` | 求职进度时间轴 | `fetchJobProgress` | |
|||
| `GET /jobs/favorites` | 我的收藏岗位 | `fetchFavorites` | |
|||
| `GET /conversations` | 沟通会话列表 | `fetchConversations` | |
|||
| `GET /notifications` | 系统通知 | `fetchNotifications` | |
|||
|
|||
### 🟡 中优先级 |
|||
|
|||
| 前端路径 | 用途 | 前端函数 | |
|||
|---|---|---| |
|||
| `GET /jobs/compare` | 岗位对比 | `fetchJobCompare` | |
|||
| `GET /offers/compare` | Offer 对比 | `fetchOfferCompare` | |
|||
| `GET /profile/me/preferences` | 求职偏好 | `fetchJobPreference` | |
|||
| `PUT /profile/me/preferences` | 保存求职偏好 | 更新 | |
|||
| `POST /jobs/{id}/reviews` | 发布评价 | `submitReview` | |
|||
| `GET /interviews/me` | 面试列表 | `fetchMyInterviews` | |
|||
| `GET /interviews/{id}/score` | 面试评分 | `fetchInterviewScore` | |
|||
| `GET /company/candidates/{id}` | 候选人详情 | `fetchCandidateDetail` | |
|||
|
|||
### 🔴 已对齐但后端仍有问题 |
|||
|
|||
| 端点 | 问题 | |
|||
|---|---| |
|||
| `POST /auth/sms/send` | `sms sender unavailable`(短信网关未通,无 mock code 旁路) | |
|||
| `GET /jobs/search` / `GET /jobs` / `GET /companies/{id}/jobs` | `items: []`(后端无 job seed 数据) | |
|||
| `GET /profile/me` | 返回空对象 `{}`(后端未定义 STUDENT profile 字段) | |
|||
| `GET /companies/{id}` | 只有 id=1 有数据(企业 seed 不足) | |
|||
File diff suppressed because it is too large
@ -0,0 +1,59 @@ |
|||
import json, re |
|||
|
|||
# Load real OpenAPI paths |
|||
with open("scripts/openapi.json", encoding="utf-8") as f: |
|||
spec = json.load(f) |
|||
real_paths = set() |
|||
for p, methods in spec.get("paths", {}).items(): |
|||
for m in methods: |
|||
real_paths.add((m.upper(), p)) |
|||
|
|||
# Load api.ts |
|||
with open("src/api/api.ts", encoding="utf-8") as f: |
|||
lines = f.readlines() |
|||
|
|||
# Extract per-function guessed request path + 待确认 flag |
|||
func_re = re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(") |
|||
req_re = re.compile(r"request(?:<[^>]*>)?\(\s*['\"]([^'\"]+)['\"]") |
|||
dai_re = re.compile(r"【待确认】") |
|||
|
|||
cur = None |
|||
funcs = {} # name -> {path, dai, lineno} |
|||
for i, ln in enumerate(lines): |
|||
m = func_re.match(ln) |
|||
if m: |
|||
cur = m.group(1) |
|||
funcs[cur] = {"path": None, "dai": False, "lineno": i+1} |
|||
if cur: |
|||
rm = req_re.search(ln) |
|||
if rm and funcs[cur]["path"] is None: |
|||
funcs[cur]["path"] = rm.group(1) |
|||
if dai_re.search(ln): |
|||
funcs[cur]["dai"] = True |
|||
|
|||
# Real paths set for existence check (ignore method for domain check) |
|||
all_real_path_strings = set(p for _, p in real_paths) |
|||
|
|||
print("=== FUNCTIONS WITH 【待确认】 GUESSED PATH ===") |
|||
phantom = [] |
|||
exists = [] |
|||
no_real_branch = [] |
|||
for name, info in funcs.items(): |
|||
if not info["dai"]: |
|||
continue |
|||
path = info["path"] |
|||
if path is None: |
|||
no_real_branch.append(name) |
|||
print(f" [NO-REAL-BRANCH] {name} (line {info['lineno']})") |
|||
continue |
|||
if path in all_real_path_strings: |
|||
exists.append((name, path)) |
|||
print(f" [REAL?] {name:42} -> {path} (line {info['lineno']})") |
|||
else: |
|||
phantom.append((name, path)) |
|||
print(f" [PHANTOM] {name:42} -> {path} (line {info['lineno']})") |
|||
|
|||
print() |
|||
print(f"REAL-DOMAIN (path string exists somewhere): {len(exists)}") |
|||
print(f"PHANTOM (path NOT in OpenAPI at all): {len(phantom)}") |
|||
print(f"【待确认】 but NO request path found: {len(no_real_branch)}") |
|||
@ -0,0 +1,23 @@ |
|||
import re |
|||
with open("src/api/api.ts", encoding="utf-8") as f: |
|||
lines = f.readlines() |
|||
|
|||
func_re = re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\((.*?)\)\s*:") |
|||
req_re = re.compile(r"request(?:<[^>]*>)?\(") |
|||
|
|||
targets = ["fetchConversations","fetchNotifications","fetchInterviews","fetchMatchResult", |
|||
"fetchResume","fetchFavorites","fetchJobProgress","fetchJobPreference","saveJobPreference", |
|||
"fetchCandidateDetail","fetchInterviewScore","fetchInterviewScoreDraft","fetchConversation", |
|||
"fetchRankData"] |
|||
|
|||
cur=None; sig=None |
|||
for i,ln in enumerate(lines): |
|||
m=func_re.match(ln) |
|||
if m: |
|||
cur=m.group(1); sig=m.group(2) |
|||
if cur in targets: |
|||
if req_re.search(ln): |
|||
print(f"--- {cur}({sig.strip()}) line {i+1}") |
|||
print(" ", ln.rstrip()) |
|||
# also catch request spanning: unlikely |
|||
print("\nDONE") |
|||
@ -0,0 +1,66 @@ |
|||
/** |
|||
* 投递(Application)相关类型定义 |
|||
* 后端 OpenAPI 文档中字段名为 generic object,以下字段基于端点语义推断, |
|||
* 待后端补全字段名后再收敛。 |
|||
*/ |
|||
|
|||
/** 投递状态(后端 timeline 端点暗示的状态枚举) */ |
|||
export type ApplicationStatus = |
|||
| 'submitted' // 已投递
|
|||
| 'viewed' // 已查看
|
|||
| 'shortlisted' // 进入候选
|
|||
| 'interview' // 面试中
|
|||
| 'offer' // 已发 Offer
|
|||
| 'accepted' // 已接受 Offer
|
|||
| 'rejected' // 已拒绝
|
|||
| 'withdrawn'; // 已撤回
|
|||
|
|||
/** 投递列表项(/applications/me 返回的 items) */ |
|||
export interface ApplicationItem { |
|||
id: string; |
|||
jobId: string; |
|||
/** 岗位名称(前端 UI 展示需要) */ |
|||
positionName?: string; |
|||
/** 企业名称 */ |
|||
companyName?: string; |
|||
/** 企业 logo URL */ |
|||
companyLogo?: string; |
|||
/** 工作地点 */ |
|||
location?: string; |
|||
/** 薪资范围 */ |
|||
salaryText?: string; |
|||
/** 投递状态 */ |
|||
status: ApplicationStatus; |
|||
/** 投递时间(ISO 字符串) */ |
|||
createdAt?: string; |
|||
/** 最后更新时间 */ |
|||
updatedAt?: string; |
|||
} |
|||
|
|||
/** 投递详情(/applications/{id}) */ |
|||
export interface ApplicationDetail extends ApplicationItem { |
|||
/** 求职信 */ |
|||
coverLetter?: string; |
|||
/** 简历 ID */ |
|||
resumeId?: string; |
|||
/** 前端透传的额外字段 */ |
|||
[key: string]: unknown; |
|||
} |
|||
|
|||
/** 投递状态时间线节点 */ |
|||
export interface ApplicationTimelineEvent { |
|||
status: ApplicationStatus; |
|||
/** 时间(ISO 字符串) */ |
|||
time?: string; |
|||
/** 备注,如"HR 查看了你的简历" */ |
|||
note?: string; |
|||
} |
|||
|
|||
/** 分页结果(后端统一分页结构) */ |
|||
export interface PageResult<T> { |
|||
items: T[]; |
|||
total: number; |
|||
size: number; |
|||
page: number; |
|||
totalPages: number; |
|||
} |
|||
Loading…
Reference in new issue