Browse Source

chore: 联调审计脚本与 OpenAPI 快照

- 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
doublex001 4 weeks ago
parent
commit
e4edbef9e2
  1. 83
      scripts/audit_all_paths.py
  2. 119
      scripts/docx_text.txt
  3. 36
      scripts/extract_docx.py
  4. 156
      scripts/gen_correction_map.py
  5. 76
      scripts/live_validate.py
  6. 1
      scripts/openapi.json
  7. 20
      scripts/openapi_paths.py
  8. 188
      scripts/transform_api.py

83
scripts/audit_all_paths.py

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

119
scripts/docx_text.txt

@ -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、发生时间和脱敏请求。

36
scripts/extract_docx.py

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

156
scripts/gen_correction_map.py

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

76
scripts/live_validate.py

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

1
scripts/openapi.json

File diff suppressed because one or more lines are too long

20
scripts/openapi_paths.py

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

188
scripts/transform_api.py

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