Compare commits
10 Commits
928c310c36
...
e4edbef9e2
| Author | SHA1 | Date |
|---|---|---|
|
|
e4edbef9e2 | 4 weeks ago |
|
|
1b1f6e573d | 4 weeks ago |
|
|
86c543c3d7 | 4 weeks ago |
|
|
55a9222e77 | 4 weeks 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