Compare commits
10 Commits
928c310c36
...
e4edbef9e2
| Author | SHA1 | Date |
|---|---|---|
|
|
e4edbef9e2 | 1 month ago |
|
|
1b1f6e573d | 1 month ago |
|
|
86c543c3d7 | 1 month ago |
|
|
55a9222e77 | 1 month ago |
|
|
075f7c1f41 | 1 month ago |
|
|
3ef1df2eab | 1 month ago |
|
|
c16ce63039 | 1 month ago |
|
|
410b1b7386 | 1 month ago |
|
|
aeaa0472ab | 1 month ago |
|
|
b0ed17b17b | 1 month ago |
131 changed files with 5155 additions and 1243 deletions
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 383 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@ -0,0 +1,109 @@ |
|||||
|
# 联调路径校正映射表(基于 job.youthome.com.cn OpenAPI 实测) |
||||
|
|
||||
|
> 生成日期:2026-09-28 | 数据源:`scripts/openapi.json`(182 条真实路径 / 220 path-methods) |
||||
|
> 审计范围:api.ts 内全部 `request()` 调用(89 个函数) |
||||
|
> 结论:ALIGNED(已对齐)= 6,WRONG→REAL(路径错但有真实等价)= 14,PHANTOM(幽灵域,后端无契约)= 69 |
||||
|
|
||||
|
## 一、已对齐(无需改路径) |
||||
|
|
||||
|
| 函数 | 真实路径 | 原标记 | |
||||
|
|---|---|---| |
||||
|
| `createJobReview` | `POST /jobs/{jobId}/reviews` | 已校正 | |
||||
|
| `fetchCompareData` | `/jobs/compare` | 已校正 | |
||||
|
| `fetchEmployeeReviews` | `GET /jobs/{jobId}/reviews` | 已校正 | |
||||
|
| `fetchJobDetail` | `GET /jobs/{jobId}` | 已校正 | |
||||
|
| `fetchOfferCompareData` | `GET /offers/compare` | 【待确认】 | |
||||
|
| `fetchUserProfile` | `GET /profile/me` | 已校正 | |
||||
|
|
||||
|
## 二、路径错但有真实等价端点(需改路径) |
||||
|
|
||||
|
| 函数 | 当前(错) | 应改为(真实) | 说明 | |
||||
|
|---|---|---|---| |
||||
|
| `fetchCandidateDetail` | `/enterprise/candidates/${candidateId}` | `/company/candidates/{applicationId}` | 前端猜 /enterprise/candidates,真路径在 /company 域 | |
||||
|
| `fetchConversation` | `/chat/conversations/${id}` | `GET /conversations/{id}` | 前端猜 /chat/conversations,真路径 /conversations/{id} | |
||||
|
| `fetchConversations` | `/messages/conversations` | `GET /conversations` | 前端猜 /messages/conversations,真 /conversations | |
||||
|
| `fetchInterviewScore` | `/enterprise/interviews/${interviewId}/score` | `GET /interviews/{interviewId}/score` | 前端猜 /enterprise/interviews,真路径在 /interviews 域 | |
||||
|
| `fetchInterviewScoreDraft` | `/enterprise/interviews/${interviewId}/score/draft` | `PUT /interviews/{interviewId}/score` | 真路径无 /draft 子路径,保存草稿用 PUT /interviews/{id}/score | |
||||
|
| `fetchInterviews` | `/messages/interviews` | `GET /interviews/me` | 前端猜 /messages/interviews,真 /interviews/me(我的面试列表) | |
||||
|
| `fetchJobPreference` | `/profile/me/job-preference` | `GET/PUT /profile/me/preferences` | 前端猜 /profile/me/job-preference,真路径 /profile/me/preferences | |
||||
|
| `fetchJobProgress` | `/tools/job-progress` | `GET /profile/me/career-progress` | 前端猜 /tools/job-progress,真 /profile/me/career-progress(统一求职进度聚合) | |
||||
|
| `fetchMatchResult` | `/messages/match-result` | `GET /applications/{id}/match 或 /jobs/{jobId}/match` | 前端猜 /messages/match-result,真为投递/岗位匹配诊断 | |
||||
|
| `fetchNotifications` | `/messages/notifications` | `GET /notifications` | 前端猜 /messages/notifications,真 /notifications | |
||||
|
| `fetchRankData` | `/jobs/rank` | `GET /jobs/{jobId}/competition-ranking` | 前端猜 /jobs/rank,真为按岗位竞争力排名,语义不同(需 jobId) | |
||||
|
| `fetchResume` | `/resume` | `GET /profile/me/resume` | 前端猜 /resume,真 /profile/me/resume(下载简历) | |
||||
|
| `saveJobPreference` | `/profile/me/job-preference` | `PUT /profile/me/preferences` | 同上 | |
||||
|
| `saveResumeBasicInfo` | `/resume/basic-info` | `POST /profile/me/resume (multipart)` | 前端猜 /resume/basic-info,真为上传简历文件(multipart),结构化字段在 PUT /profile/me | |
||||
|
|
||||
|
## 三、幽灵域(后端 OpenAPI 无契约,必须退回 mock-only) |
||||
|
|
||||
|
| 函数 | 当前(幽灵)路径 | 说明 | |
||||
|
|---|---|---| |
||||
|
| `deleteSalaryRecord` | `/salary/records/${id}` | 无 /salary 域,后端未提供 | |
||||
|
| `fetchAIInterviewData` | `/interview/config` | 未命中 OpenAPI | |
||||
|
| `fetchAITools` | `/growth/ai-tools` | 未命中 OpenAPI | |
||||
|
| `fetchAbilityAssessment` | `/growth/ability` | 未命中 OpenAPI | |
||||
|
| `fetchAboutInfo` | `/settings/about` | 未命中 OpenAPI | |
||||
|
| `fetchAccountCancel` | `/settings/account-cancel` | 未命中 OpenAPI | |
||||
|
| `fetchAccountSecurity` | `/settings/account-security` | 未命中 OpenAPI | |
||||
|
| `fetchAppealForm` | `/evaluation/appeal` | 未命中 OpenAPI | |
||||
|
| `fetchArticles` | `/growth/articles` | 未命中 OpenAPI | |
||||
|
| `fetchAuditReport` | `/operation/audit-report` | 未命中 OpenAPI | |
||||
|
| `fetchBadges` | `/user/badges` | 未命中 OpenAPI | |
||||
|
| `fetchDimensionScores` | `/user/dimension-scores` | 未命中 OpenAPI | |
||||
|
| `fetchDndSetting` | `/settings/privacy/dnd` | 未命中 OpenAPI | |
||||
|
| `fetchEnterpriseCertification` | `/enterprise/certification` | 未命中 OpenAPI | |
||||
|
| `fetchEnterpriseCommunication` | `/enterprise/communication` | 未命中 OpenAPI | |
||||
|
| `fetchEnterpriseEntry` | `/user/enterprise-entry` | 未命中 OpenAPI | |
||||
|
| `fetchEnterpriseOverview` | `/enterprise/overview` | 未命中 OpenAPI | |
||||
|
| `fetchGameLevels` | `/game/levels` | 未命中 OpenAPI | |
||||
|
| `fetchGameProgress` | `/game/progress` | 未命中 OpenAPI | |
||||
|
| `fetchHREvaluation` | `/evaluation/hr` | 未命中 OpenAPI | |
||||
|
| `fetchHollandAssessment` | `/growth/holland` | 未命中 OpenAPI | |
||||
|
| `fetchHollandIntro` | `/growth/holland-intro` | 未命中 OpenAPI | |
||||
|
| `fetchIdentityOptions` | `/tools/identity-options` | 未命中 OpenAPI | |
||||
|
| `fetchJobEvaluationEmptyState` | `/enterprise/job-evaluation-empty` | 未命中 OpenAPI | |
||||
|
| `fetchJobEvaluationManagement` | `/enterprise/job-evaluations` | 未命中 OpenAPI | |
||||
|
| `fetchJobManagement` | `/enterprise/job-management` | 未命中 OpenAPI | |
||||
|
| `fetchJobOverallScore` | `/jobs/${id}/score` | 无 /jobs/{id}/score,综合分见 /jobs/{id}/competition-ranking 或 /profile/me/candidate-scoring | |
||||
|
| `fetchJobPublishForm` | `/enterprise/jobs/publish-form` | 未命中 OpenAPI | |
||||
|
| `fetchKeywordBlock` | `/settings/privacy/keywords` | 未命中 OpenAPI | |
||||
|
| `fetchLegalBrand` | `/legal/brand` | 未命中 OpenAPI | |
||||
|
| `fetchLegalConversation` | `/legal/conversations/default` | 未命中 OpenAPI | |
||||
|
| `fetchLegalDocument` | `/settings/legal-document/${id}` | 无 /legal 域,后端未提供 | |
||||
|
| `fetchLegalFAQs` | `/legal/faqs` | 未命中 OpenAPI | |
||||
|
| `fetchLegalMembership` | `/legal/membership` | 未命中 OpenAPI | |
||||
|
| `fetchLegalOrders` | `/legal/orders` | 未命中 OpenAPI | |
||||
|
| `fetchLegalPlans` | `/legal/plans` | 未命中 OpenAPI | |
||||
|
| `fetchLegalServiceRequests` | `/legal/service-requests` | 未命中 OpenAPI | |
||||
|
| `fetchLegalServices` | `/legal/services` | 未命中 OpenAPI | |
||||
|
| `fetchLegalTemplates` | `/legal/templates` | 未命中 OpenAPI | |
||||
|
| `fetchMenuItems` | `/user/menus` | 未命中 OpenAPI | |
||||
|
| `fetchNotificationSettings` | `/settings/notification` | 未命中 OpenAPI | |
||||
|
| `fetchOperationRules` | `/operation/rules` | 未命中 OpenAPI | |
||||
|
| `fetchOverallScore` | `/user/overall-score` | 未命中 OpenAPI | |
||||
|
| `fetchPendingOfferCount` | `/offers/pending/count` | 无 /offers/pending/count,可用 /offers/statistics 替代(需确认) | |
||||
|
| `fetchPersonalDataExport` | `/settings/personal-data-export` | 未命中 OpenAPI | |
||||
|
| `fetchPrivacyCenter` | `/settings/privacy-center` | 未命中 OpenAPI | |
||||
|
| `fetchPrivacyDetailSetting` | `/settings/privacy/${id}` | 无 /settings 域,后端未提供 | |
||||
|
| `fetchPrivacySettings` | `/settings/privacy` | 未命中 OpenAPI | |
||||
|
| `fetchQualifications` | `/settings/qualifications` | 未命中 OpenAPI | |
||||
|
| `fetchResumeExportSettings` | `/resume/export-settings` | 无 /resume/export-settings,导出设置本地维护 | |
||||
|
| `fetchResumeSavedAt` | `/resume/saved-at` | 无 /resume/saved-at,简历保存时间可由 /profile/me/resume-versions 派生 | |
||||
|
| `fetchSalaryBrackets` | `/tools/salary-brackets` | 未命中 OpenAPI | |
||||
|
| `fetchSalaryData` | `/jobs/${id}/salary` | 无 /jobs/{id}/salary,后端未提供薪资明细端点 | |
||||
|
| `fetchSalaryRecords` | `/salary/records` | 未命中 OpenAPI | |
||||
|
| `fetchSalaryToolLinks` | `/tools/salary-related` | 未命中 OpenAPI | |
||||
|
| `fetchSkillCloud` | `/tools/skill-cloud` | 未命中 OpenAPI | |
||||
|
| `fetchSkillLearningPath` | `/tools/skill-learning-path` | 未命中 OpenAPI | |
||||
|
| `fetchTalentRecommendation` | `/enterprise/talent-recommendation` | 未命中 OpenAPI | |
||||
|
| `fetchUserStats` | `/user/stats` | 未命中 OpenAPI | |
||||
|
| `fetchWeightSettings` | `/jobs/weight-settings` | 无 /jobs/weight-settings,权重见 /profile/me/review-weights、/review-weight-templates | |
||||
|
| `saveResumeEducation` | `/resume/education` | 无 /resume/education,简历整体由 /profile/me/resume + resume-versions 管理 | |
||||
|
| `saveResumeExperience` | `/resume/experience` | 同上 | |
||||
|
| `saveResumeExportSettings` | `/resume/export-settings` | 未命中 OpenAPI | |
||||
|
| `saveResumeProjects` | `/resume/projects` | 同上 | |
||||
|
| `saveResumeSelfEvaluation` | `/resume/self-evaluation` | 同上 | |
||||
|
| `saveResumeSkills` | `/resume/skills` | 同上 | |
||||
|
| `saveResumeSnapshot` | `/resume/save` | 同上 | |
||||
|
| `saveSalaryRecord` | `/salary/records` | 无 /salary/records | |
||||
|
| `saveWeightSettings` | `/weight-settings` | 无 /weight-settings | |
||||
@ -0,0 +1,83 @@ |
|||||
|
import json, re |
||||
|
|
||||
|
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(p) |
||||
|
|
||||
|
# Load api.ts |
||||
|
with open("src/api/api.ts", encoding="utf-8") as f: |
||||
|
text = f.read() |
||||
|
lines = text.splitlines() |
||||
|
|
||||
|
func_re = re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(") |
||||
|
# static literal request path |
||||
|
static_req = re.compile(r"request(?:<[^>]*>)?\(\s*['\"]([^'\"]+)['\"]") |
||||
|
# template literal request path like request(`/x/${..}`) |
||||
|
tmpl_req = re.compile(r"request(?:<[^>]*>)?\(\s*`([^`]*)`") |
||||
|
|
||||
|
cur = None |
||||
|
funcs = {} # name -> {static:[], tmpl:[], dai:bool, line} |
||||
|
for i, ln in enumerate(lines): |
||||
|
m = func_re.match(ln) |
||||
|
if m: |
||||
|
cur = m.group(1) |
||||
|
funcs[cur] = {"static": [], "tmpl": [], "dai": False, "line": i+1} |
||||
|
if cur: |
||||
|
for sm in static_req.finditer(ln): |
||||
|
funcs[cur]["static"].append(sm.group(1)) |
||||
|
for tm in tmpl_req.finditer(ln): |
||||
|
funcs[cur]["tmpl"].append(tm.group(1)) |
||||
|
if "【待确认】" in ln: |
||||
|
funcs[cur]["dai"] = True |
||||
|
|
||||
|
# Determine if a guessed path has ANY real backing (prefix match ignoring method) |
||||
|
def has_real_backing(path): |
||||
|
# exact |
||||
|
if path in real_paths: |
||||
|
return True |
||||
|
# base segment match: first 2 segments |
||||
|
segs = [s for s in path.split("/") if s and not "{" in s and not "$" in s] |
||||
|
if len(segs) >= 1: |
||||
|
base = "/" + segs[0] |
||||
|
if any(rp.startswith(base + "/") or rp == base for rp in real_paths): |
||||
|
return "partial" |
||||
|
return False |
||||
|
|
||||
|
phantom_static = [] |
||||
|
ok_static = [] |
||||
|
partial_static = [] |
||||
|
tmpl_funcs = [] |
||||
|
for name, info in funcs.items(): |
||||
|
if not info["static"] and not info["tmpl"]: |
||||
|
continue |
||||
|
for sp in info["static"]: |
||||
|
b = has_real_backing(sp) |
||||
|
if b is True: |
||||
|
ok_static.append((name, sp)) |
||||
|
elif b == "partial": |
||||
|
partial_static.append((name, sp)) |
||||
|
else: |
||||
|
phantom_static.append((name, sp)) |
||||
|
for tp in info["tmpl"]: |
||||
|
tmpl_funcs.append((name, tp)) |
||||
|
|
||||
|
print("DISTINCT FUNCTIONS WITH request():", sum(1 for n,i in funcs.items() if i["static"] or i["tmpl"])) |
||||
|
print() |
||||
|
print("=== PHANTOM STATIC PATHS (no OpenAPI backing at all) ===") |
||||
|
for n, p in sorted(set(phantom_static)): |
||||
|
dai = "【待确认】" if funcs[n]["dai"] else "(已标'已校正'但仍是幽灵!)" |
||||
|
print(f" {n:42} -> {p:48} {dai}") |
||||
|
print(f"\nPHANTOM count (unique fn-path): {len(set(phantom_static))}") |
||||
|
print(f"OK count: {len(set(ok_static))}") |
||||
|
print(f"PARTIAL count: {len(set(partial_static))}") |
||||
|
print() |
||||
|
print("=== PARTIAL (base domain exists, path differs) ===") |
||||
|
for n, p in sorted(set(partial_static)): |
||||
|
print(f" {n:42} -> {p}") |
||||
|
print() |
||||
|
print("=== TEMPLATE-LITERAL request paths (need manual method check) ===") |
||||
|
for n, p in sorted(set(tmpl_funcs)): |
||||
|
print(f" {n:42} -> {p}") |
||||
@ -0,0 +1,119 @@ |
|||||
|
职来后端接口联调说明 |
||||
|
前端开发使用 线上联调环境 2026年9月22日 |
||||
|
结论:前端统一通过 HTTPS 域名访问后端,业务请求前缀为 https://job.youthome.com.cn/api/v1。除注册、登录、短信验证码、刷新 Token 等匿名接口外,其余接口需要在 Authorization 请求头携带 Access Token。 |
||||
|
一 联调地址 |
||||
|
|
||||
|
[TABLE] |
||||
|
用途 | 地址 | 使用说明 |
||||
|
业务接口基地址 | https://job.youthome.com.cn/api/v1 | 前端环境变量中的 API Base URL |
||||
|
健康检查 | https://job.youthome.com.cn/api/v1/health | 返回 code=200 且 data.status=UP 表示后端可用 |
||||
|
OpenAPI JSON | https://job.youthome.com.cn/api/v1/v3/api-docs | Apifox 导入或更新接口定义使用 |
||||
|
WebSocket | wss://job.youthome.com.cn/api/v1/ws | 仅在对应实时功能启用时使用;不要使用 ws:// |
||||
|
|
||||
|
|
||||
|
重要:不要直接请求服务器 IP、Jenkins 地址或 8010 端口。前端、Apifox 和浏览器都统一使用 HTTPS 域名,避免跨域、证书和环境切换问题。 |
||||
|
二 Apifox 配置方法 |
||||
|
在 Apifox 项目中选择导入数据,导入方式选 URL,填写 OpenAPI JSON 地址。 |
||||
|
环境前置 URL 设置为 https://job.youthome.com.cn/api/v1。接口路径本身只保留 /auth/me、/jobs 等相对路径。 |
||||
|
创建环境变量 AuthToken,用登录响应中的 accessToken 赋值。 |
||||
|
需要认证的接口统一设置请求头 Authorization: Bearer {{AuthToken}}。 |
||||
|
接口有调整时重新从 OpenAPI URL 增量导入,不要手工复制旧接口覆盖新定义。 |
||||
|
三 通用请求和响应规则 |
||||
|
|
||||
|
[TABLE] |
||||
|
项目 | 规则 |
||||
|
请求格式 | 普通业务接口使用 application/json;上传简历等文件接口使用 multipart/form-data。 |
||||
|
认证请求头 | Authorization: Bearer <accessToken>。Refresh Token 不能代替 Access Token 访问业务接口。 |
||||
|
请求追踪 | 可传 X-Request-Id;后端会在响应头和响应体返回 requestId,报错联调时请一并提供。 |
||||
|
时间格式 | 按 OpenAPI 定义发送 ISO 8601 日期或日期时间,不要自行转换为中文日期。 |
||||
|
分页 | 以接口定义中的 page、size 为准;不要在前端假设页码起点。 |
||||
|
响应外层 | 通常为 code、message、data、requestId、timestamp。业务数据读取 data。 |
||||
|
|
||||
|
|
||||
|
Authorization: Bearer {{AuthToken}}Content-Type: application/jsonX-Request-Id: web-20260922-0001 |
||||
|
{ "code": 200, "message": "ok", "data": { }, "requestId": "...", "timestamp": "..."} |
||||
|
四 登录与会话使用顺序 |
||||
|
|
||||
|
[TABLE] |
||||
|
步骤 | 接口 | 前端处理 |
||||
|
1 | POST /auth/sms/send | 按 purpose 发送验证码;purpose 必须使用后端支持的固定值。 |
||||
|
2A | POST /auth/password/login | 手机号和密码登录;成功后保存 accessToken、refreshToken。 |
||||
|
2B | POST /auth/login | 短信验证码登录;现接口文档标为 Mock 登录,生产前端不要作为正式入口,除非产品和后端明确确认。 |
||||
|
3 | GET /auth/me | 登录后拉取用户 id、角色和当前身份信息。 |
||||
|
4 | POST /auth/refresh | Access Token 过期时,用 Refresh Token 换取新 Token。 |
||||
|
5 | POST /auth/logout | 退出登录;成功后清除前端保存的所有 Token 和用户状态。 |
||||
|
|
||||
|
|
||||
|
Token 保存与刷新建议 |
||||
|
Access Token 用于业务请求;Refresh Token 只提交给 /auth/refresh 和会话相关接口。 |
||||
|
遇到 401 时只允许一次串行刷新;刷新成功后重放原请求,刷新失败则清理会话并跳转登录页。 |
||||
|
不要把 Token 打印到控制台、错误上报、URL 查询参数或页面 DOM。 |
||||
|
不同浏览器标签页同时刷新时要加前端互斥,避免多个 Refresh Token 并发重放。 |
||||
|
五 学生 企业 管理员的区别 |
||||
|
|
||||
|
[TABLE] |
||||
|
身份 | 账号产生方式 | 登录方式 | 主要接口前缀 |
||||
|
STUDENT | 现有短信登录流程可自动创建学生账号 | 短信登录或已设置密码后的密码登录 | /profile/me、/applications、/offers、/interviews |
||||
|
COMPANY | POST /auth/company/register 专用注册 | 注册成功直接返回 Token;之后可密码登录 | /companies/me、/company、/jobs |
||||
|
ADMIN | 不提供公开注册,由管理员初始化或后台受控创建 | 使用现有认证方式登录 | /admin |
||||
|
|
||||
|
|
||||
|
前端路由判断:登录成功后以 /auth/me 返回的角色为准。不要让客户端提交 role 来决定身份,也不要仅依据页面入口猜测用户角色。 |
||||
|
六 企业注册与入驻流程 |
||||
|
|
||||
|
[TABLE] |
||||
|
顺序 | 接口 | 预期结果 |
||||
|
1 | POST /auth/sms/send | purpose 使用 company-register,获取企业注册验证码。 |
||||
|
2 | POST /auth/company/register | 提交手机号、验证码、密码和企业基础资料;成功返回 COMPANY Token。 |
||||
|
3 | GET /auth/me | 确认当前角色为 COMPANY。 |
||||
|
4 | GET /companies/me/onboarding | 读取 UNVERIFIED、PENDING、REJECTED 或 VERIFIED,以及 missingFields、allowedActions。 |
||||
|
5 | PUT /companies/me | 根据 missingFields 完善企业资料。 |
||||
|
6 | POST /companies/me/verification | 提交企业认证材料。 |
||||
|
7 | GET /companies/me/onboarding | 刷新聚合状态;前端按钮以 allowedActions 为准。 |
||||
|
|
||||
|
|
||||
|
企业注册请求示例 |
||||
|
POST /auth/company/register{ "phone": "19900000000", "code": "123456", "password": "符合密码策略的密码", "company": { "name": "示例科技有限公司", "industry": "互联网", "description": "企业简介", "website": "https://example.com", "address": "企业地址" }} |
||||
|
注意:客户端不能提交 userId、role、verified 或 passwordHash。注册响应中的 accessToken 和 refreshToken 按普通登录响应处理。 |
||||
|
七 常用业务接口导航 |
||||
|
|
||||
|
[TABLE] |
||||
|
业务模块 | 常用接口 | 认证要求 |
||||
|
公开岗位 | GET /jobs、GET /jobs/{jobId}、GET /jobs/search、GET /jobs/filters | 公开读取;具体以 OpenAPI security 定义为准 |
||||
|
学生资料 | GET/PUT /profile/me、POST/GET/DELETE /profile/me/resume | STUDENT |
||||
|
求职流程 | 投递、面试、Offer、消息等接口 | 登录后按角色和资源归属校验 |
||||
|
企业资料 | GET/PUT /companies/me | COMPANY |
||||
|
企业入驻 | GET /companies/me/onboarding、POST/GET /companies/me/verification | COMPANY |
||||
|
企业岗位 | POST/PUT /jobs 及发布关闭接口 | COMPANY;部分能力可能要求完成认证 |
||||
|
管理员 | /admin 下审核、风控、举报等接口 | ADMIN |
||||
|
|
||||
|
|
||||
|
完整字段、枚举、必填项、响应结构和全部接口以运行时 OpenAPI 为唯一联调依据。本文只提供入口和使用顺序。 |
||||
|
八 常见状态码处理 |
||||
|
|
||||
|
[TABLE] |
||||
|
HTTP | 含义 | 前端建议 |
||||
|
400 | 参数错误、验证码错误或业务输入不合法 | 展示安全业务文案;字段错误定位到表单项。 |
||||
|
401 | 未登录、Token 无效或过期 | 尝试一次刷新;失败后清理会话并回登录页。 |
||||
|
403 | 角色不允许或无资源权限 | 展示无权限页面,不要循环刷新 Token。 |
||||
|
404 | 资源不存在 | 展示资源不存在或返回列表页。 |
||||
|
409 | 账号、状态或并发冲突 | 刷新当前数据后提示用户重新操作。 |
||||
|
429 | 请求过于频繁 | 禁用按钮并显示倒计时,不要立即自动重试。 |
||||
|
500 | 服务内部错误 | 显示通用错误,并上报 requestId。 |
||||
|
503 | 依赖服务不可用或功能暂不可用 | 提示稍后重试,避免高频轮询。 |
||||
|
|
||||
|
|
||||
|
九 前端联调检查清单 |
||||
|
环境 Base URL 仅配置一次,接口路径中不重复拼接 /api/v1。 |
||||
|
登录后 /auth/me 能返回正确角色,Authorization 头格式正确。 |
||||
|
401 刷新流程不会无限循环,403 不触发刷新。 |
||||
|
企业注册先发送 company-register 用途验证码,再调用企业注册接口。 |
||||
|
企业页面从 /companies/me/onboarding 获取状态和 allowedActions,不在前端自行推导认证状态。 |
||||
|
上传接口使用 multipart/form-data,不手动覆盖浏览器生成的 boundary。 |
||||
|
报错时保留 HTTP 状态、业务 code、message、requestId 和请求时间;删除 Token、手机号、验证码及密码后再发给后端排查。 |
||||
|
联调前先访问健康检查;健康为 UP 后再判断具体业务接口问题。 |
||||
|
十 安全和协作约定 |
||||
|
禁止在前端代码、Git、群聊截图或 Apifox 公共变量中保存数据库、Redis、RabbitMQ、Jenkins 密码。 |
||||
|
禁止把 Access Token、Refresh Token、短信验证码和用户密码写入日志或错误上报。 |
||||
|
接口字段调整由后端更新 OpenAPI,前端重新导入;不要以聊天记录替代接口定义。 |
||||
|
需要后端排查时提供:环境、接口、方法、HTTP 状态、业务 code、requestId、发生时间和脱敏请求。 |
||||
@ -0,0 +1,36 @@ |
|||||
|
import zipfile, sys |
||||
|
from xml.etree import ElementTree as ET |
||||
|
|
||||
|
path = "职来后端接口联调说明.docx" |
||||
|
z = zipfile.ZipFile(path) |
||||
|
xml = z.read("word/document.xml").decode("utf-8") |
||||
|
|
||||
|
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} |
||||
|
root = ET.fromstring(xml) |
||||
|
|
||||
|
def para_text(p): |
||||
|
texts = [] |
||||
|
for t in p.iter("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t"): |
||||
|
texts.append(t.text or "") |
||||
|
return "".join(texts) |
||||
|
|
||||
|
body = root.find("w:body", ns) |
||||
|
out = [] |
||||
|
for el in body: |
||||
|
tag = el.tag.split("}")[-1] |
||||
|
if tag == "p": |
||||
|
out.append(para_text(el)) |
||||
|
elif tag == "tbl": |
||||
|
rows = [] |
||||
|
for tr in el.iter("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tr"): |
||||
|
cells = [para_text(p) for p in tr.iter("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p")] |
||||
|
rows.append(" | ".join(cells)) |
||||
|
out.append("\n[TABLE]\n" + "\n".join(rows) + "\n") |
||||
|
|
||||
|
full = "\n".join(out) |
||||
|
with open("scripts/docx_text.txt", "w", encoding="utf-8") as f: |
||||
|
f.write(full) |
||||
|
print("TOTAL CHARS:", len(full)) |
||||
|
for i, line in enumerate(out): |
||||
|
if any(k in line for k in ["OpenAPI", "接口", "路径", "endpoint", "/api", "GET", "POST", "§7", "七、"]): |
||||
|
print(f"[{i}] {line[:140]}") |
||||
@ -0,0 +1,156 @@ |
|||||
|
import json, re |
||||
|
|
||||
|
with open("scripts/openapi.json", encoding="utf-8") as f: |
||||
|
spec = json.load(f) |
||||
|
real_paths = set() |
||||
|
real_path_methods = {} |
||||
|
for p, methods in spec.get("paths", {}).items(): |
||||
|
real_paths.add(p) |
||||
|
for m in methods: |
||||
|
real_path_methods.setdefault(p, []).append(m.upper()) |
||||
|
|
||||
|
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*\(") |
||||
|
static_req = re.compile(r"request(?:<[^>]*>)?\(\s*['\"]([^'\"]+)['\"]") |
||||
|
tmpl_req = re.compile(r"request(?:<[^>]*>)?\(\s*`([^`]*)`") |
||||
|
dai_re = re.compile(r"【待确认】") |
||||
|
|
||||
|
cur = None |
||||
|
funcs = {} |
||||
|
for i, ln in enumerate(lines): |
||||
|
m = func_re.match(ln) |
||||
|
if m: |
||||
|
cur = m.group(1) |
||||
|
funcs[cur] = {"static": [], "tmpl": [], "dai": False, "line": i+1} |
||||
|
if cur: |
||||
|
for sm in static_req.finditer(ln): |
||||
|
funcs[cur]["static"].append(sm.group(1)) |
||||
|
for tm in tmpl_req.finditer(ln): |
||||
|
funcs[cur]["tmpl"].append(tm.group(1)) |
||||
|
if dai_re.search(ln): |
||||
|
funcs[cur]["dai"] = True |
||||
|
|
||||
|
# Manual real-equivalent judgment for template/partial/path-wrong functions |
||||
|
# value: (verdict, real_path_or_None, note) |
||||
|
manual = { |
||||
|
# template literals - verify |
||||
|
"createJobReview": ("ALIGNED", "POST /jobs/{jobId}/reviews", "路径命中真实契约"), |
||||
|
"fetchEmployeeReviews": ("ALIGNED", "GET /jobs/{jobId}/reviews", "路径命中真实契约"), |
||||
|
"fetchJobDetail": ("ALIGNED", "GET /jobs/{jobId}", "路径命中真实契约"), |
||||
|
"fetchCandidateDetail": ("WRONG->REAL", "/company/candidates/{applicationId}", "前端猜 /enterprise/candidates,真路径在 /company 域"), |
||||
|
"fetchInterviewScore": ("WRONG->REAL", "GET /interviews/{interviewId}/score", "前端猜 /enterprise/interviews,真路径在 /interviews 域"), |
||||
|
"fetchInterviewScoreDraft": ("WRONG->REAL", "PUT /interviews/{interviewId}/score", "真路径无 /draft 子路径,保存草稿用 PUT /interviews/{id}/score"), |
||||
|
"fetchConversation": ("WRONG->REAL", "GET /conversations/{id}", "前端猜 /chat/conversations,真路径 /conversations/{id}"), |
||||
|
"deleteSalaryRecord": ("PHANTOM", None, "无 /salary 域,后端未提供"), |
||||
|
"fetchLegalDocument": ("PHANTOM", None, "无 /legal 域,后端未提供"), |
||||
|
"fetchPrivacyDetailSetting": ("PHANTOM", None, "无 /settings 域,后端未提供"), |
||||
|
"fetchSalaryData": ("PHANTOM", None, "无 /jobs/{id}/salary,后端未提供薪资明细端点"), |
||||
|
"fetchJobOverallScore": ("PHANTOM", None, "无 /jobs/{id}/score,综合分见 /jobs/{id}/competition-ranking 或 /profile/me/candidate-scoring"), |
||||
|
# partial static |
||||
|
"fetchJobPreference": ("WRONG->REAL", "GET/PUT /profile/me/preferences", "前端猜 /profile/me/job-preference,真路径 /profile/me/preferences"), |
||||
|
"saveJobPreference": ("WRONG->REAL", "PUT /profile/me/preferences", "同上"), |
||||
|
"fetchPendingOfferCount": ("PHANTOM", None, "无 /offers/pending/count,可用 /offers/statistics 替代(需确认)"), |
||||
|
"fetchRankData": ("WRONG->REAL", "GET /jobs/{jobId}/competition-ranking", "前端猜 /jobs/rank,真为按岗位竞争力排名,语义不同(需 jobId)"), |
||||
|
"fetchWeightSettings": ("PHANTOM", None, "无 /jobs/weight-settings,权重见 /profile/me/review-weights、/review-weight-templates"), |
||||
|
# already-correct static |
||||
|
"fetchJobList": ("ALIGNED", "GET /jobs", "命中真实契约"), |
||||
|
"fetchOfferCompareData": ("ALIGNED", "GET /offers/compare", "命中真实契约"), |
||||
|
"fetchUserProfile": ("ALIGNED", "GET /profile/me", "命中真实契约"), |
||||
|
# path-wrong (phantom base) that have real equivalents elsewhere |
||||
|
"fetchConversations": ("WRONG->REAL", "GET /conversations", "前端猜 /messages/conversations,真 /conversations"), |
||||
|
"fetchNotifications": ("WRONG->REAL", "GET /notifications", "前端猜 /messages/notifications,真 /notifications"), |
||||
|
"fetchInterviews": ("WRONG->REAL", "GET /interviews/me", "前端猜 /messages/interviews,真 /interviews/me(我的面试列表)"), |
||||
|
"fetchMatchResult": ("WRONG->REAL", "GET /applications/{id}/match 或 /jobs/{jobId}/match", "前端猜 /messages/match-result,真为投递/岗位匹配诊断"), |
||||
|
"fetchResume": ("WRONG->REAL", "GET /profile/me/resume", "前端猜 /resume,真 /profile/me/resume(下载简历)"), |
||||
|
"fetchFavorites": ("WRONG->REAL", "GET /jobs/favorites", "前端猜 /profile/me/favorites,真 /jobs/favorites"), |
||||
|
"fetchJobProgress": ("WRONG->REAL", "GET /profile/me/career-progress", "前端猜 /tools/job-progress,真 /profile/me/career-progress(统一求职进度聚合)"), |
||||
|
"fetchResumeSavedAt": ("PHANTOM", None, "无 /resume/saved-at,简历保存时间可由 /profile/me/resume-versions 派生"), |
||||
|
"fetchResumeExportSettings": ("PHANTOM", None, "无 /resume/export-settings,导出设置本地维护"), |
||||
|
"saveResumeBasicInfo": ("WRONG->REAL", "POST /profile/me/resume (multipart)", "前端猜 /resume/basic-info,真为上传简历文件(multipart),结构化字段在 PUT /profile/me"), |
||||
|
"saveResumeEducation": ("PHANTOM", None, "无 /resume/education,简历整体由 /profile/me/resume + resume-versions 管理"), |
||||
|
"saveResumeExperience": ("PHANTOM", None, "同上"), |
||||
|
"saveResumeProjects": ("PHANTOM", None, "同上"), |
||||
|
"saveResumeSelfEvaluation": ("PHANTOM", None, "同上"), |
||||
|
"saveResumeSkills": ("PHANTOM", None, "同上"), |
||||
|
"saveResumeSnapshot": ("PHANTOM", None, "同上"), |
||||
|
"saveSalaryRecord": ("PHANTOM", None, "无 /salary/records"), |
||||
|
"saveWeightSettings": ("PHANTOM", None, "无 /weight-settings"), |
||||
|
} |
||||
|
|
||||
|
def base_has_real(p): |
||||
|
segs = [s for s in p.split("/") if s and "{" not in s and "$" not in s] |
||||
|
if not segs: |
||||
|
return False |
||||
|
base = "/" + segs[0] |
||||
|
return any(rp == base or rp.startswith(base + "/") for rp in real_paths) |
||||
|
|
||||
|
verdicts = {} |
||||
|
for name, info in funcs.items(): |
||||
|
if not info["static"] and not info["tmpl"]: |
||||
|
continue |
||||
|
if name in manual: |
||||
|
v, rp, note = manual[name] |
||||
|
verdicts[name] = (v, rp, note, info["dai"], info["line"]) |
||||
|
continue |
||||
|
# auto |
||||
|
paths = info["static"] + info["tmpl"] |
||||
|
# for static-only auto |
||||
|
allphantom = True |
||||
|
anyok = False |
||||
|
for p in info["static"]: |
||||
|
if p in real_paths: |
||||
|
anyok = True; allphantom = False |
||||
|
elif base_has_real(p): |
||||
|
allphantom = False |
||||
|
if anyok: |
||||
|
verdicts[name] = ("ALIGNED", ", ".join(info["static"]), "命中真实契约", info["dai"], info["line"]) |
||||
|
elif allphantom is False and info["static"]: |
||||
|
verdicts[name] = ("PHANTOM", ", ".join(info["static"]), "父域不存在于 OpenAPI", info["dai"], info["line"]) |
||||
|
else: |
||||
|
verdicts[name] = ("PHANTOM", ", ".join(paths), "未命中 OpenAPI", info["dai"], info["line"]) |
||||
|
|
||||
|
# also catch functions flagged dai but no request captured (e.g. buildPrivacySummaryText) - skip |
||||
|
|
||||
|
order = {"ALIGNED":0, "WRONG->REAL":1, "PHANTOM":2} |
||||
|
rows = sorted(verdicts.items(), key=lambda kv: (order.get(kv[1][0],9), kv[0])) |
||||
|
|
||||
|
from collections import Counter |
||||
|
c = Counter(v[0] for v in verdicts.values()) |
||||
|
print("SUMMARY:", dict(c), "TOTAL FUNCS MAPPED:", len(verdicts)) |
||||
|
|
||||
|
md = ["# 联调路径校正映射表(基于 job.youthome.com.cn OpenAPI 实测)", ""] |
||||
|
md.append(f"> 生成日期:2026-09-28 | 数据源:`scripts/openapi.json`(182 条真实路径 / 220 path-methods)") |
||||
|
md.append(f"> 审计范围:api.ts 内全部 `request()` 调用({len(verdicts)} 个函数)") |
||||
|
md.append(f"> 结论:ALIGNED(已对齐)= {c.get('ALIGNED',0)},WRONG→REAL(路径错但有真实等价)= {c.get('WRONG->REAL',0)},PHANTOM(幽灵域,后端无契约)= {c.get('PHANTOM',0)}") |
||||
|
md.append("") |
||||
|
md.append("## 一、已对齐(无需改路径)") |
||||
|
md.append("") |
||||
|
md.append("| 函数 | 真实路径 | 原标记 |") |
||||
|
md.append("|---|---|---|") |
||||
|
for n,(v,rp,note,dai,line) in rows: |
||||
|
if v=="ALIGNED": |
||||
|
md.append(f"| `{n}` | `{rp}` | {'【待确认】' if dai else '已校正'} |") |
||||
|
md.append("") |
||||
|
md.append("## 二、路径错但有真实等价端点(需改路径)") |
||||
|
md.append("") |
||||
|
md.append("| 函数 | 当前(错) | 应改为(真实) | 说明 |") |
||||
|
md.append("|---|---|---|---|") |
||||
|
for n,(v,rp,note,dai,line) in rows: |
||||
|
if v=="WRONG->REAL": |
||||
|
curpath = ", ".join(funcs[n]["static"]+funcs[n]["tmpl"]) |
||||
|
md.append(f"| `{n}` | `{curpath}` | `{rp}` | {note} |") |
||||
|
md.append("") |
||||
|
md.append("## 三、幽灵域(后端 OpenAPI 无契约,必须退回 mock-only)") |
||||
|
md.append("") |
||||
|
md.append("| 函数 | 当前(幽灵)路径 | 说明 |") |
||||
|
md.append("|---|---|---|") |
||||
|
for n,(v,rp,note,dai,line) in rows: |
||||
|
if v=="PHANTOM": |
||||
|
curpath = ", ".join(funcs[n]["static"]+funcs[n]["tmpl"]) |
||||
|
md.append(f"| `{n}` | `{curpath}` | {note} |") |
||||
|
md.append("") |
||||
|
with open("docs/联调路径校正映射.md", "w", encoding="utf-8") as f: |
||||
|
f.write("\n".join(md)) |
||||
|
print("WROTE docs/联调路径校正映射.md") |
||||
@ -0,0 +1,76 @@ |
|||||
|
import json, urllib.request, urllib.error |
||||
|
|
||||
|
BASE = "https://job.youthome.com.cn/api/v1" |
||||
|
|
||||
|
def req(method, path, token=None, body=None): |
||||
|
url = BASE + path |
||||
|
data = json.dumps(body).encode() if body is not None else None |
||||
|
r = urllib.request.Request(url, data=data, method=method) |
||||
|
r.add_header("Content-Type", "application/json") |
||||
|
if token: |
||||
|
r.add_header("Authorization", "Bearer " + token) |
||||
|
try: |
||||
|
with urllib.request.urlopen(r, timeout=20) as resp: |
||||
|
raw = resp.read().decode() |
||||
|
status = resp.status |
||||
|
except urllib.error.HTTPError as e: |
||||
|
raw = e.read().decode(errors="replace") |
||||
|
status = e.code |
||||
|
except Exception as e: |
||||
|
return None, str(e), None |
||||
|
try: |
||||
|
j = json.loads(raw) |
||||
|
except Exception: |
||||
|
return status, raw[:200], None |
||||
|
return status, j, raw |
||||
|
|
||||
|
# 1) login |
||||
|
s, j, _ = req("POST", "/auth/password/login", body={"phone": "19900009021", "password": "ZhilaiTest!2026#A"}) |
||||
|
token = (j or {}).get("data", {}).get("accessToken") if j else None |
||||
|
print("LOGIN:", s, "hasToken=", bool(token), "role=", (j or {}).get("data", {}).get("role"), "expiresIn=", (j or {}).get("data", {}).get("en")) |
||||
|
print("="*100) |
||||
|
|
||||
|
# 2) validate ID-less GET endpoints (student token) |
||||
|
idless = [ |
||||
|
("GET", "/jobs", "fetchJobList(ALIGNED)"), |
||||
|
("GET", "/offers/compare", "fetchOfferCompareData(ALIGNED)"), |
||||
|
("GET", "/profile/me", "fetchUserProfile(ALIGNED)"), |
||||
|
("GET", "/conversations", "fetchConversations(WRONG->REAL /conversations)"), |
||||
|
("GET", "/notifications", "fetchNotifications(WRONG->REAL /notifications)"), |
||||
|
("GET", "/notifications/unread-count", "bonus real"), |
||||
|
("GET", "/interviews/me", "fetchInterviews(WRONG->REAL /interviews/me)"), |
||||
|
("GET", "/jobs/favorites", "fetchFavorites(WRONG->REAL /jobs/favorites)"), |
||||
|
("GET", "/profile/me/career-progress", "fetchJobProgress(WRONG->REAL /profile/me/career-progress)"), |
||||
|
("GET", "/profile/me/preferences", "fetchJobPreference(WRONG->REAL /profile/me/preferences)"), |
||||
|
("GET", "/profile/me/resume", "fetchResume(WRONG->REAL /profile/me/resume)"), |
||||
|
("GET", "/profile/me/candidate-scoring", "bonus real"), |
||||
|
("GET", "/applications/me", "bonus real /applications/me"), |
||||
|
("GET", "/assessments/me", "bonus real /assessments/me"), |
||||
|
("GET", "/abilities/me", "bonus real /abilities/me"), |
||||
|
] |
||||
|
for m, p, label in idless: |
||||
|
s, j, _ = req(m, p, token) |
||||
|
code = (j or {}).get("code") if isinstance(j, dict) else None |
||||
|
data = (j or {}).get("data") |
||||
|
dtype = type(data).__name__ |
||||
|
keys = list(data.keys())[:12] if isinstance(data, dict) else (f"len={len(data)}" if isinstance(data, list) else None) |
||||
|
print(f"{m:4} {p:42} [{label}]") |
||||
|
print(f" http={s} code={code} data_type={dtype} sample={keys}") |
||||
|
|
||||
|
print("="*100) |
||||
|
# 3) ID-needing endpoints: best-effort with placeholder id (status ambiguous if 404) |
||||
|
idneed = [ |
||||
|
("GET", "/jobs/1", "fetchJobDetail(ALIGNED) needs jobId"), |
||||
|
("GET", "/jobs/1/reviews", "fetchEmployeeReviews(ALIGNED)"), |
||||
|
("GET", "/jobs/1/competition-ranking", "fetchRankData(WRONG->REAL)"), |
||||
|
("GET", "/jobs/1/match", "fetchMatchResult alt"), |
||||
|
("GET", "/conversations/1", "fetchConversation(WRONG->REAL /conversations/{id})"), |
||||
|
("GET", "/applications/1/match", "fetchMatchResult(WRONG->REAL)"), |
||||
|
("GET", "/company/candidates/1", "fetchCandidateDetail(WRONG->REAL, ENTERPRISE role)"), |
||||
|
("GET", "/interviews/1/score", "fetchInterviewScore(WRONG->REAL)"), |
||||
|
] |
||||
|
for m, p, label in idneed: |
||||
|
s, j, _ = req(m, p, token) |
||||
|
code = (j or {}).get("code") if isinstance(j, dict) else None |
||||
|
msg = (j or {}).get("message") if isinstance(j, dict) else None |
||||
|
print(f"{m:4} {p:42} [{label}] http={s} code={code} msg={msg}") |
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,20 @@ |
|||||
|
import json |
||||
|
|
||||
|
with open("scripts/openapi.json", encoding="utf-8") as f: |
||||
|
spec = json.load(f) |
||||
|
|
||||
|
paths = spec.get("paths", {}) |
||||
|
rows = [] |
||||
|
for p, methods in sorted(paths.items()): |
||||
|
for m, op in methods.items(): |
||||
|
if m.lower() not in ("get","post","put","delete","patch"): |
||||
|
continue |
||||
|
summary = op.get("summary") or op.get("description") or "" |
||||
|
tags = ",".join(op.get("tags", [])) |
||||
|
rows.append((m.upper(), p, summary, tags)) |
||||
|
|
||||
|
print(f"TOTAL PATH-METHODS: {len(rows)}") |
||||
|
print(f"TOTAL DISTINCT PATHS: {len(set(r[1] for r in rows))}") |
||||
|
print("="*100) |
||||
|
for m, p, s, t in rows: |
||||
|
print(f"{m:6} {p:55} [{t}] {s[:40]}") |
||||
@ -0,0 +1,188 @@ |
|||||
|
import json, re |
||||
|
|
||||
|
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(p) |
||||
|
real_base = set() |
||||
|
for p in real_paths: |
||||
|
seg = [s for s in p.split("/") if s and "{" not in s and "$" not in s] |
||||
|
if seg: |
||||
|
real_base.add("/" + seg[0]) |
||||
|
|
||||
|
with open("src/api/api.ts", encoding="utf-8") as f: |
||||
|
raw = f.read() |
||||
|
lines = raw.split("\n") # keep as list, we will rejoin |
||||
|
|
||||
|
func_re = re.compile(r"^(\s*)(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(") |
||||
|
static_req = re.compile(r"request(?:<[^>]*>)?\(\s*['\"]([^'\"]+)['\"]") |
||||
|
tmpl_req = re.compile(r"request(?:<[^>]*>)?\(\s*`([^`]*)`") |
||||
|
|
||||
|
# find function start indices |
||||
|
starts = [] |
||||
|
for i, ln in enumerate(lines): |
||||
|
m = func_re.match(ln) |
||||
|
if m: |
||||
|
starts.append((m.group(2), i)) |
||||
|
func_ranges = [] |
||||
|
for idx, (name, s) in enumerate(starts): |
||||
|
e = starts[idx+1][1] if idx+1 < len(starts) else len(lines) |
||||
|
func_ranges.append((name, s, e)) |
||||
|
|
||||
|
PATH_FIX_STATIC = { |
||||
|
"fetchConversations": ("'/messages/conversations'", "'/conversations'"), |
||||
|
"fetchNotifications": ("'/messages/notifications'", "'/notifications'"), |
||||
|
"fetchInterviews": ("'/messages/interviews'", "'/interviews/me'"), |
||||
|
"fetchJobPreference": ("'/profile/me/job-preference'", "'/profile/me/preferences'"), |
||||
|
"saveJobPreference": ("'/profile/me/job-preference'", "'/profile/me/preferences'"), |
||||
|
"fetchResume": ("'/resume'", "'/profile/me/resume'"), |
||||
|
"fetchFavorites": ("'/profile/me/favorites'", "'/jobs/favorites'"), |
||||
|
} |
||||
|
JOBPROGRESS_OLD = "'/tools/job-progress', { method: 'GET', auth: false }" |
||||
|
JOBPROGRESS_NEW = "'/profile/me/career-progress', { method: 'GET' }" |
||||
|
TEMPLATE_FIX = { |
||||
|
"fetchRankData": ("/jobs/rank", "/jobs/${jobId}/competition-ranking"), |
||||
|
"fetchCandidateDetail": ("/enterprise/candidates/${candidateId}", "/company/candidates/${candidateId}"), |
||||
|
"fetchInterviewScore": ("/enterprise/interviews/${interviewId}/score", "/interviews/${interviewId}/score"), |
||||
|
"fetchInterviewScoreDraft": ("/enterprise/interviews/${interviewId}/score/draft", "/interviews/${interviewId}/score"), |
||||
|
"fetchConversation": ("/chat/conversations/${id}", "/conversations/${id}"), |
||||
|
} |
||||
|
|
||||
|
def get_info(sl): |
||||
|
static = []; tmpl = [] |
||||
|
hmi = any("if (isMockMode())" in l for l in sl) |
||||
|
hnmi = any("if (!isMockMode())" in l for l in sl) |
||||
|
for l in sl: |
||||
|
m = static_req.search(l) |
||||
|
if m: static.append(m.group(1)) |
||||
|
t = tmpl_req.search(l) |
||||
|
if t: tmpl.append(t.group(1)) |
||||
|
return static, tmpl, hmi, hnmi |
||||
|
|
||||
|
def is_phantom(p): |
||||
|
if p in real_paths: return False |
||||
|
segs = [s for s in p.split("/") if s and "{" not in s and "$" not in s] |
||||
|
base = "/" + segs[0] if segs else "" |
||||
|
return base not in real_base |
||||
|
|
||||
|
DAI_RE = re.compile(r'【待确认[^】]*】') |
||||
|
|
||||
|
def strip_dai(line): |
||||
|
if DAI_RE.search(line): |
||||
|
return DAI_RE.sub('', line).rstrip() + "\n" |
||||
|
return line |
||||
|
|
||||
|
changed = {"pathfix": [], "revert": [], "strip_only": []} |
||||
|
new_full = [] |
||||
|
|
||||
|
def find_close(sl, open_i): |
||||
|
depth = 0 |
||||
|
for j in range(open_i, len(sl)): |
||||
|
depth += sl[j].count("{") - sl[j].count("}") |
||||
|
if depth == 0 and j > open_i: |
||||
|
return j |
||||
|
return None |
||||
|
|
||||
|
# preserve leading lines before the first function (imports, top-level consts, banners) |
||||
|
new_full = list(lines[0:func_ranges[0][1]]) |
||||
|
|
||||
|
for name, s, e in func_ranges: |
||||
|
sl = lines[s:e] |
||||
|
static, tmpl, hmi, hnmi = get_info(sl) |
||||
|
in_openapi = any(p in real_paths for p in static) or any(not is_phantom(t) for t in tmpl) |
||||
|
dai = any("【待确认】" in l for l in sl) |
||||
|
is_pathfix = name in PATH_FIX_STATIC or name in TEMPLATE_FIX or name == "fetchJobProgress" |
||||
|
phantom = (dai or any(is_phantom(p) for p in static) or any(is_phantom(t) for t in tmpl)) and not in_openapi |
||||
|
|
||||
|
if is_pathfix: |
||||
|
out = [] |
||||
|
for ln in sl: |
||||
|
if name in PATH_FIX_STATIC: |
||||
|
o, n = PATH_FIX_STATIC[name] |
||||
|
if o in ln: ln = ln.replace(o, n) |
||||
|
ou = o.strip("'\"") |
||||
|
nu = n.strip("'\"") |
||||
|
if ou in ln: ln = ln.replace(ou, nu) |
||||
|
elif name == "fetchJobProgress": |
||||
|
if JOBPROGRESS_OLD in ln: ln = ln.replace(JOBPROGRESS_OLD, JOBPROGRESS_NEW) |
||||
|
if "/tools/job-progress" in ln: ln = ln.replace("/tools/job-progress", "/profile/me/career-progress") |
||||
|
elif name in TEMPLATE_FIX: |
||||
|
o, n = TEMPLATE_FIX[name] |
||||
|
if o in ln: ln = ln.replace(o, n) |
||||
|
ou = o.replace("`", "") |
||||
|
nu = n.replace("`", "") |
||||
|
if ou in ln: ln = ln.replace(ou, nu) |
||||
|
out.append(strip_dai(ln)) |
||||
|
new_full.extend(out) |
||||
|
changed["pathfix"].append(name) |
||||
|
continue |
||||
|
|
||||
|
if not phantom: |
||||
|
if dai: |
||||
|
new_full.extend(strip_dai(ln) for ln in sl) |
||||
|
else: |
||||
|
new_full.extend(sl) |
||||
|
continue |
||||
|
|
||||
|
# REVERT (phantom -> always mock) |
||||
|
if hnmi: |
||||
|
out = [] |
||||
|
i = 0 |
||||
|
while i < len(sl): |
||||
|
if "if (!isMockMode())" in sl[i]: |
||||
|
ci = find_close(sl, i) |
||||
|
i = (ci + 1) if ci is not None else i + 1 |
||||
|
continue |
||||
|
out.append(strip_dai(sl[i])) |
||||
|
i += 1 |
||||
|
new_full.extend(out) |
||||
|
changed["revert"].append(name) |
||||
|
else: |
||||
|
# pattern A: collapse if(isMockMode()){...} and drop trailing return request |
||||
|
out = [] |
||||
|
i = 0 |
||||
|
collapsed = False |
||||
|
while i < len(sl): |
||||
|
if "if (isMockMode())" in sl[i]: |
||||
|
ci = find_close(sl, i) |
||||
|
if ci is not None: |
||||
|
inner = sl[i+1:ci] |
||||
|
inner_out = [] |
||||
|
for il in inner: |
||||
|
if "return request" in il: |
||||
|
continue |
||||
|
if il.lstrip().startswith("//") and "【待确认】" in il: |
||||
|
continue |
||||
|
inner_out.append(il) |
||||
|
inner_out = [l[2:] if l.startswith(" ") else l for l in inner_out] |
||||
|
out.append(" // 后端未提供 OpenAPI 契约:保持 mock(翻真实开关也不触网),待后端就绪再接真实分支") |
||||
|
out.extend(inner_out) |
||||
|
i = ci + 1 |
||||
|
collapsed = True |
||||
|
continue |
||||
|
if "return request" in sl[i]: |
||||
|
i += 1 |
||||
|
continue |
||||
|
out.append(strip_dai(sl[i])) |
||||
|
i += 1 |
||||
|
if not collapsed: |
||||
|
# dai but no isMockMode block and no real branch -> just strip |
||||
|
changed["strip_only"].append(name) |
||||
|
else: |
||||
|
changed["revert"].append(name) |
||||
|
new_full.extend(out) |
||||
|
|
||||
|
# preserve trailing lines after the last function |
||||
|
new_full.extend(lines[func_ranges[-1][2]:]) |
||||
|
|
||||
|
with open("src/api/api.ts", "w", encoding="utf-8") as f: |
||||
|
f.write("\n".join(new_full)) |
||||
|
|
||||
|
print("PATHFIX:", len(changed["pathfix"])) |
||||
|
print("REVERT:", len(changed["revert"])) |
||||
|
print("STRIP_ONLY:", len(changed["strip_only"]), changed["strip_only"]) |
||||
|
print("--- revert ---") |
||||
|
for n in changed["revert"]: |
||||
|
print(" ", n) |
||||
@ -0,0 +1,249 @@ |
|||||
|
/** |
||||
|
* 后端桥接(剩余域)consolidated 测试 |
||||
|
* 覆盖第三轮之后批量转换的 62 个 fetch 函数: |
||||
|
* - 真实态(setMockMode(false)):仅对已与后端 OpenAPI 对齐的函数断言 request 命中真实路径 |
||||
|
* (见 REAL_FUNCS;其余为幽灵域,已退回 mock-only,翻真实开关也不触网,不在此断言) |
||||
|
* - mock 态(默认):断言返回非 undefined(防止 mock 分支被意外破坏) |
||||
|
* 路径依据 scripts/openapi.json(job.youthome.com.cn)实测校正,不再标【待确认】。 |
||||
|
*/ |
||||
|
// expo-constants:extra 为空 → 走默认生产域名;mock 开关由 setMockMode 控制
|
||||
|
jest.mock('expo-constants', () => ({ expoConfig: { extra: {} } })); |
||||
|
|
||||
|
// token-store:内存双 Token,避免依赖 AsyncStorage
|
||||
|
jest.mock('../lib/token-store', () => { |
||||
|
let mem: { accessToken: string; refreshToken: string; expiresAt?: number } | null = null; |
||||
|
return { |
||||
|
getAccessToken: jest.fn(() => mem?.accessToken ?? null), |
||||
|
getTokens: jest.fn(() => mem), |
||||
|
setTokens: jest.fn((a: string, r: string, e?: number) => { |
||||
|
mem = { accessToken: a, refreshToken: r, expiresAt: e }; |
||||
|
}), |
||||
|
clearTokens: jest.fn(() => { |
||||
|
mem = null; |
||||
|
}), |
||||
|
hydrateTokens: jest.fn(async () => undefined), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
// storage:内存实现,避免依赖 AsyncStorage
|
||||
|
jest.mock('../lib/storage', () => { |
||||
|
const m = new Map<string, string>(); |
||||
|
return { |
||||
|
storageGet: jest.fn(async (k: string) => m.get(k) ?? null), |
||||
|
storageSet: jest.fn(async (k: string, v: string) => { |
||||
|
m.set(k, v); |
||||
|
}), |
||||
|
storageRemove: jest.fn(async (k: string) => { |
||||
|
m.delete(k); |
||||
|
}), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
import { |
||||
|
fetchArticles, |
||||
|
fetchMenuItems, |
||||
|
fetchEnterpriseEntry, |
||||
|
fetchRankData, |
||||
|
fetchSkillCloud, |
||||
|
fetchSkillLearningPath, |
||||
|
fetchSalaryToolLinks, |
||||
|
fetchSalaryBrackets, |
||||
|
fetchOperationRules, |
||||
|
fetchHollandIntro, |
||||
|
fetchIdentityOptions, |
||||
|
fetchGameLevels, |
||||
|
fetchLegalBrand, |
||||
|
fetchLegalServices, |
||||
|
fetchLegalFAQs, |
||||
|
fetchLegalTemplates, |
||||
|
fetchLegalPlans, |
||||
|
fetchAboutInfo, |
||||
|
fetchQualifications, |
||||
|
fetchAITools, |
||||
|
fetchHistory, |
||||
|
fetchHollandAssessment, |
||||
|
fetchHollandResult, |
||||
|
fetchAbilityAssessment, |
||||
|
fetchUserStats, |
||||
|
fetchOverallScore, |
||||
|
fetchDimensionScores, |
||||
|
fetchBadges, |
||||
|
fetchOfferCompareData, |
||||
|
fetchPendingOfferCount, |
||||
|
fetchWeightSettings, |
||||
|
fetchSalaryRecords, |
||||
|
fetchAIInterviewData, |
||||
|
fetchHREvaluation, |
||||
|
fetchAppealForm, |
||||
|
fetchEnterpriseOverview, |
||||
|
fetchJobPublishForm, |
||||
|
fetchJobEvaluationManagement, |
||||
|
fetchCandidateDetail, |
||||
|
fetchInterviewScore, |
||||
|
fetchInterviewScoreDraft, |
||||
|
fetchAuditReport, |
||||
|
fetchEnterpriseCertification, |
||||
|
fetchJobManagement, |
||||
|
fetchTalentRecommendation, |
||||
|
fetchEnterpriseCommunication, |
||||
|
fetchJobEvaluationEmptyState, |
||||
|
fetchGameProgress, |
||||
|
fetchLegalMembership, |
||||
|
fetchLegalOrders, |
||||
|
fetchLegalServiceRequests, |
||||
|
fetchLegalConversation, |
||||
|
fetchPrivacySettings, |
||||
|
fetchPrivacyCenter, |
||||
|
fetchPrivacyDetailSetting, |
||||
|
fetchDndSetting, |
||||
|
fetchKeywordBlock, |
||||
|
fetchAccountCancel, |
||||
|
fetchLegalDocument, |
||||
|
fetchPersonalDataExport, |
||||
|
fetchNotificationSettings, |
||||
|
fetchAccountSecurity, |
||||
|
} from '../api/api'; |
||||
|
import { setMockMode } from '../api/http'; |
||||
|
|
||||
|
// [fn, args, expectedPath, mockSmoke]
|
||||
|
type Entry = [ |
||||
|
(...a: any[]) => Promise<any>, |
||||
|
any[], |
||||
|
string, |
||||
|
boolean, |
||||
|
]; |
||||
|
|
||||
|
const FUNCS: Entry[] = [ |
||||
|
// 公开读取(auth:false)
|
||||
|
[fetchArticles, [], '/growth/articles', true], |
||||
|
[fetchMenuItems, ['candidate'], '/user/menus', true], |
||||
|
[fetchEnterpriseEntry, [], '/user/enterprise-entry', true], |
||||
|
[fetchRankData, ['1'], '/jobs/rank', false], |
||||
|
[fetchSkillCloud, ['it'], '/tools/skill-cloud', false], |
||||
|
[fetchSkillLearningPath, ['it'], '/tools/skill-learning-path', false], |
||||
|
[fetchSalaryToolLinks, [], '/tools/salary-related', true], |
||||
|
[fetchSalaryBrackets, [], '/tools/salary-brackets', true], |
||||
|
[fetchOperationRules, [], '/operation/rules', true], |
||||
|
[fetchHollandIntro, [], '/growth/holland-intro', true], |
||||
|
[fetchIdentityOptions, [], '/tools/identity-options', true], |
||||
|
[fetchGameLevels, [], '/game/levels', true], |
||||
|
[fetchLegalBrand, [], '/legal/brand', true], |
||||
|
[fetchLegalServices, [], '/legal/services', true], |
||||
|
[fetchLegalFAQs, [], '/legal/faqs', true], |
||||
|
[fetchLegalTemplates, [], '/legal/templates', true], |
||||
|
[fetchLegalPlans, [], '/legal/plans', true], |
||||
|
[fetchAboutInfo, [], '/settings/about', true], |
||||
|
[fetchQualifications, [], '/settings/qualifications', true], |
||||
|
[fetchAITools, [], '/growth/ai-tools', true], |
||||
|
// 用户/企业私有(auth:true)
|
||||
|
[fetchHistory, [], '/user/history', true], |
||||
|
[fetchHollandAssessment, [], '/growth/holland', true], |
||||
|
[fetchHollandResult, [], '/growth/holland/result', true], |
||||
|
[fetchAbilityAssessment, [], '/growth/ability', true], |
||||
|
[fetchUserStats, [], '/user/stats', true], |
||||
|
[fetchOverallScore, [], '/user/overall-score', true], |
||||
|
[fetchDimensionScores, [], '/user/dimension-scores', true], |
||||
|
[fetchBadges, [], '/user/badges', true], |
||||
|
[fetchOfferCompareData, [undefined], '/offers/compare', true], |
||||
|
[fetchPendingOfferCount, [], '/offers/pending/count', true], |
||||
|
[fetchWeightSettings, [], '/jobs/weight-settings', true], |
||||
|
[fetchSalaryRecords, [], '/salary/records', true], |
||||
|
[fetchAIInterviewData, [], '/interview/config', true], |
||||
|
[fetchHREvaluation, [], '/evaluation/hr', true], |
||||
|
[fetchAppealForm, [], '/evaluation/appeal', true], |
||||
|
[fetchEnterpriseOverview, [], '/enterprise/overview', true], |
||||
|
[fetchJobPublishForm, [], '/enterprise/jobs/publish-form', true], |
||||
|
[fetchJobEvaluationManagement, [], '/enterprise/job-evaluations', true], |
||||
|
[fetchCandidateDetail, ['1'], '/enterprise/candidates/1', false], |
||||
|
[fetchInterviewScore, ['1'], '/enterprise/interviews/1/score', false], |
||||
|
[fetchInterviewScoreDraft, ['1'], '/enterprise/interviews/1/score/draft', false], |
||||
|
[fetchAuditReport, [], '/operation/audit-report', true], |
||||
|
[fetchEnterpriseCertification, [], '/enterprise/certification', true], |
||||
|
[fetchJobManagement, [], '/enterprise/job-management', true], |
||||
|
[fetchTalentRecommendation, [], '/enterprise/talent-recommendation', true], |
||||
|
[fetchEnterpriseCommunication, [], '/enterprise/communication', true], |
||||
|
[fetchJobEvaluationEmptyState, [], '/enterprise/job-evaluation-empty', true], |
||||
|
[fetchGameProgress, [], '/game/progress', true], |
||||
|
[fetchLegalMembership, [], '/legal/membership', true], |
||||
|
[fetchLegalOrders, [], '/legal/orders', true], |
||||
|
[fetchLegalServiceRequests, [], '/legal/service-requests', true], |
||||
|
[fetchLegalConversation, [], '/legal/conversations/default', true], |
||||
|
[fetchPrivacySettings, [], '/settings/privacy', true], |
||||
|
[fetchPrivacyCenter, [], '/settings/privacy-center', true], |
||||
|
[fetchPrivacyDetailSetting, ['1'], '/settings/privacy/1', false], |
||||
|
[fetchDndSetting, [], '/settings/privacy/dnd', true], |
||||
|
[fetchKeywordBlock, [], '/settings/privacy/keywords', true], |
||||
|
[fetchAccountCancel, [], '/settings/account-cancel', true], |
||||
|
[fetchLegalDocument, ['1'], '/settings/legal-document/1', false], |
||||
|
[fetchPersonalDataExport, [], '/settings/personal-data-export', true], |
||||
|
[fetchNotificationSettings, [], '/settings/notification', true], |
||||
|
[fetchAccountSecurity, [], '/settings/account-security', true], |
||||
|
]; |
||||
|
|
||||
|
const ENVELOPE = { |
||||
|
code: 0, |
||||
|
message: '', |
||||
|
data: null, |
||||
|
requestId: 'r', |
||||
|
timestamp: '', |
||||
|
}; |
||||
|
|
||||
|
// 已与后端 OpenAPI 对齐(ALIGNED + WRONG→REAL)的函数:真实态打真实路径
|
||||
|
// 幽灵域(PHANTOM,后端无契约)已退回 mock-only,翻真实开关也不触网,不在此断言路径
|
||||
|
const REAL_FUNCS: Array<[(...a: any[]) => Promise<any>, any[], string]> = [ |
||||
|
[fetchRankData, ['1'], '/jobs/1/competition-ranking'], |
||||
|
[fetchCandidateDetail, ['1'], '/company/candidates/1'], |
||||
|
[fetchInterviewScore, ['1'], '/interviews/1/score'], |
||||
|
[fetchInterviewScoreDraft, ['1'], '/interviews/1/score'], |
||||
|
[fetchOfferCompareData, [undefined], '/offers/compare'], |
||||
|
]; |
||||
|
|
||||
|
describe('后端桥接 - 真实态路径断言(已对齐函数)', () => { |
||||
|
const originalFetch = (global as any).fetch; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
(global as any).fetch = jest.fn(async () => ({ |
||||
|
ok: true, |
||||
|
status: 200, |
||||
|
headers: { get: () => null }, |
||||
|
json: async () => ENVELOPE, |
||||
|
text: async () => '', |
||||
|
})); |
||||
|
setMockMode(false); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
setMockMode(true); |
||||
|
}); |
||||
|
|
||||
|
afterAll(() => { |
||||
|
(global as any).fetch = originalFetch; |
||||
|
}); |
||||
|
|
||||
|
it('已对齐函数数量正确(5 个)', () => { |
||||
|
expect(REAL_FUNCS.length).toBe(5); |
||||
|
}); |
||||
|
|
||||
|
REAL_FUNCS.forEach(([fn, args, path], i) => { |
||||
|
it(`#${i} 命中真实路径 ${path}`, async () => { |
||||
|
await fn(...args); |
||||
|
const calls = ((global as any).fetch as jest.Mock).mock.calls; |
||||
|
expect(calls.length).toBeGreaterThan(0); |
||||
|
const calledUrl: string = calls[calls.length - 1][0]; |
||||
|
expect(calledUrl).toContain(path); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('后端桥接 - mock 态冒烟(无回归)', () => { |
||||
|
beforeEach(() => { |
||||
|
setMockMode(true); |
||||
|
}); |
||||
|
|
||||
|
FUNCS.filter((e) => e[3]).forEach(([fn, args, path], idx) => { |
||||
|
it(`mock 态 ${path} 返回非 undefined`, async () => { |
||||
|
const result = await fn(...args); |
||||
|
expect(result).not.toBeUndefined(); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,141 @@ |
|||||
|
/** |
||||
|
* 后端桥接(写入类)测试 |
||||
|
* 覆盖本轮转换的 12 个写函数(简历 8 + 求职偏好 + 权重 + 薪资 2): |
||||
|
* - 真实态(setMockMode(false)):断言 request 命中预期方法 + 相对路径 + body |
||||
|
* - mock 态(默认):断言返回本地派生/生成值(证明 mock 分支未被破坏,本地 storage 真写入仍可用) |
||||
|
* 路径依据 scripts/openapi.json(job.youthome.com.cn)实测校正,不再标【待确认】。 |
||||
|
* 幽灵域写函数已退回 mock-only,真实态不在此断言网络路径。 |
||||
|
*/ |
||||
|
// expo-constants:extra 为空 → 走默认生产域名;mock 开关由 setMockMode 控制
|
||||
|
jest.mock('expo-constants', () => ({ expoConfig: { extra: {} } })); |
||||
|
|
||||
|
// token-store:内存双 Token,避免依赖 AsyncStorage
|
||||
|
jest.mock('../lib/token-store', () => { |
||||
|
let mem: { accessToken: string; refreshToken: string; expiresAt?: number } | null = null; |
||||
|
return { |
||||
|
getAccessToken: jest.fn(() => mem?.accessToken ?? null), |
||||
|
getTokens: jest.fn(() => mem), |
||||
|
setTokens: jest.fn((a: string, r: string, e?: number) => { |
||||
|
mem = { accessToken: a, refreshToken: r, expiresAt: e }; |
||||
|
}), |
||||
|
clearTokens: jest.fn(() => { |
||||
|
mem = null; |
||||
|
}), |
||||
|
hydrateTokens: jest.fn(async () => undefined), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
// storage:内存实现,避免依赖 AsyncStorage
|
||||
|
jest.mock('../lib/storage', () => { |
||||
|
const m = new Map<string, string>(); |
||||
|
return { |
||||
|
storageGet: jest.fn(async (k: string) => m.get(k) ?? null), |
||||
|
storageSet: jest.fn(async (k: string, v: string) => { |
||||
|
m.set(k, v); |
||||
|
}), |
||||
|
storageRemove: jest.fn(async (k: string) => { |
||||
|
m.delete(k); |
||||
|
}), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
import { setMockMode } from '../api/http'; |
||||
|
import { |
||||
|
saveResumeBasicInfo, |
||||
|
saveResumeSelfEvaluation, |
||||
|
saveResumeSkills, |
||||
|
saveResumeEducation, |
||||
|
saveResumeExperience, |
||||
|
saveResumeProjects, |
||||
|
saveResumeSnapshot, |
||||
|
saveResumeExportSettings, |
||||
|
saveJobPreference, |
||||
|
saveWeightSettings, |
||||
|
saveSalaryRecord, |
||||
|
deleteSalaryRecord, |
||||
|
} from '../api/api'; |
||||
|
|
||||
|
const ENVELOPE = { code: 0, message: 'ok', data: null, requestId: 'r', timestamp: 0 }; |
||||
|
|
||||
|
let fetchMock: jest.Mock; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
fetchMock = jest.fn(async () => ({ |
||||
|
ok: true, |
||||
|
status: 200, |
||||
|
json: async () => ENVELOPE, |
||||
|
text: async () => '', |
||||
|
headers: { get: () => 'x' }, |
||||
|
})); |
||||
|
(global as any).fetch = fetchMock; |
||||
|
setMockMode(false); // 默认跑真实态
|
||||
|
}); |
||||
|
|
||||
|
/** 触发一次真实请求,返回最后一个 fetch 调用的 options */ |
||||
|
async function expectRequest( |
||||
|
call: () => Promise<unknown>, |
||||
|
path: string, |
||||
|
method: string, |
||||
|
): Promise<any> { |
||||
|
await call(); |
||||
|
const last = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]; |
||||
|
expect(last[0]).toContain(path); |
||||
|
expect((last[1] as any).method).toBe(method); |
||||
|
return last[1] as any; |
||||
|
} |
||||
|
|
||||
|
describe('写入函数真实态:方法 + 相对路径 + body(仅已对齐函数)', () => { |
||||
|
beforeEach(() => setMockMode(false)); |
||||
|
|
||||
|
test('saveJobPreference → PUT /profile/me/preferences', async () => { |
||||
|
const preference = { city: '北京', salary: '15k' } as any; |
||||
|
const opts = await expectRequest( |
||||
|
() => saveJobPreference(preference), |
||||
|
'/profile/me/preferences', |
||||
|
'PUT', |
||||
|
); |
||||
|
expect(JSON.parse(opts.body)).toEqual(preference); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
/** |
||||
|
* 以下写函数属幽灵域(后端无 OpenAPI 契约),已退回 mock-only,翻真实开关也不触网, |
||||
|
* 故真实态不再断言网络路径;其 mock 态本地派生/生成行为见下方 describe。 |
||||
|
*/ |
||||
|
|
||||
|
describe('写入函数 mock 态:本地派生/生成行为不变(开发期真写入仍可用)', () => { |
||||
|
beforeEach(() => setMockMode(true)); |
||||
|
|
||||
|
test('saveSalaryRecord 本地生成 id 与时间戳', async () => { |
||||
|
const rec = await saveSalaryRecord({} as any, {} as any, 'note'); |
||||
|
expect(rec.id.startsWith('salary-')).toBe(true); |
||||
|
expect(typeof rec.createdAt).toBe('number'); |
||||
|
expect(rec.note).toBe('note'); |
||||
|
}); |
||||
|
|
||||
|
test('saveResumeBasicInfo 本地派生 avatar', async () => { |
||||
|
const info = await saveResumeBasicInfo({ name: '张三' } as any); |
||||
|
expect(info.avatar).toBeTruthy(); |
||||
|
}); |
||||
|
|
||||
|
test('saveJobPreference 原样返回入参', async () => { |
||||
|
const p = { city: '北京' } as any; |
||||
|
expect(await saveJobPreference(p)).toBe(p); |
||||
|
}); |
||||
|
|
||||
|
test('saveResumeSkills 本地去空去重', async () => { |
||||
|
const out = await saveResumeSkills([ |
||||
|
{ name: 'a' }, |
||||
|
{ name: 'a' }, |
||||
|
{ name: ' ' }, |
||||
|
] as any); |
||||
|
expect(out).toEqual([{ name: 'a' }]); |
||||
|
}); |
||||
|
|
||||
|
test('saveWeightSettings 返回副本(不与入参同引用)', async () => { |
||||
|
const dims = [{ id: 'd1' }] as any; |
||||
|
const out = await saveWeightSettings(dims); |
||||
|
expect(out).not.toBe(dims); |
||||
|
expect(out).toEqual(dims); |
||||
|
}); |
||||
|
}); |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue