Browse Source

fix(repo): 补齐仓库缺失文件(application 类型 / 后端接口契约 / 审计脚本)

**修复一个会让别人克隆后直接编译失败的坏点**:
`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
doublex001 4 weeks ago
parent
commit
fc8541a06b
  1. 196
      docs/applications-500-diagnosis.md
  2. 19645
      docs/职来后端接口.md
  3. 59
      scripts/map_apits.py
  4. 23
      scripts/scan_wrong.py
  5. 66
      src/types/application.ts

196
docs/applications-500-diagnosis.md

@ -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 不足) |

19645
docs/职来后端接口.md

File diff suppressed because it is too large

59
scripts/map_apits.py

@ -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)}")

23
scripts/scan_wrong.py

@ -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")

66
src/types/application.ts

@ -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…
Cancel
Save