Browse Source
- openapi.json:后端 OpenAPI 3.1.0 完整快照(182 路径/220 path-methods),作为路径校正唯一准绳 - transform_api.py:89 函数路径批量校正(ALIGNED/WRONG→REAL/PHANTOM 分类 + 幽灵域折叠为 mock-only) - live_validate.py:测试号实机验证脚本(登录取 token 后逐一打真实路径) - gen_correction_map.py/audit_all_paths.py/extract_docx.py/openapi_paths.py:审计与文档提取辅助 - 排除 api.ts.bak 及探索性脚本master
8 changed files with 679 additions and 0 deletions
@ -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) |
||||
Loading…
Reference in new issue