Browse Source
- 新增 useLoginLanding:验证码/密码/一键登录三入口共用的登录后落地判定,按后端 identitySelectionRequired 与服务器角色决定去身份选择还是对应端 - 新增 useIdentitySwitch:切换企业端改为调用 POST /auth/identity/select,不再本地改角色导致鉴权错位 - AuthContext/RoleContext/token-store 及微信、密码、注册各登录 Hook 与引导页适配新契约 - 移除废弃安卓自适应图标资源与根目录过期联调文档,归档 scripts/api.ts.bakmaster
41 changed files with 913 additions and 3115 deletions
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@ -1,196 +0,0 @@ |
|||||
# `/applications` 接口 500 问题诊断报告 |
|
||||
|
|
||||
> 前端联调:2026-09-29 |
|
||||
> 测试账号:`19900009021` / `ZhilaiTest!2026#A` |
|
||||
> 后端地址:`https://job.youthome.com.cn/api/v1` |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 问题复现 |
|
||||
|
|
||||
| 请求 | HTTP | code | message | |
|
||||
|---|---|---|---| |
|
||||
| `GET /applications`(带 Bearer token) | **500** | 500 | `internal server error` | |
|
||||
| `GET /applications?page=0&size=20`(带 token) | **500** | 500 | `internal server error` | |
|
||||
| `GET /applications`(不带 token) | 401 | 401 | `not authenticated` | |
|
||||
| `GET /applications/me`(带 token) | 200 | 200 | `ok` ✓ | |
|
||||
| `POST /applications`(带 token,投递) | 404 | 7001 | `job not found` ✓(正确路由,只是 jobId 不存在) | |
|
||||
| `GET /applications/me/timeline`(带 token) | 404 | 8002 | `application not found` ✓(正确路由) | |
|
||||
|
|
||||
**结论:`GET /applications` 带 token 必现 HTTP 500**,是后端 bug。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 根因分析 |
|
||||
|
|
||||
### 1. OpenAPI 文档中根本没有定义 `GET /applications` |
|
||||
|
|
||||
后端公开的 28 个端点里,applications 模块只有 **5 个**: |
|
||||
|
|
||||
| 方法 | 路径 | 用途 | 状态 | |
|
||||
|---|---|---|---| |
|
||||
| POST | `/applications` | 提交投递 | ✅ 正常(jobId 不存在时返回 404) | |
|
||||
| GET | `/applications/me` | 我的投递列表(分页) | ✅ 正常(返回 200 + 分页结构) | |
|
||||
| GET | `/applications/{id}` | 投递详情 | ✅ 正常(id 不存在返回 404) | |
|
||||
| PATCH | `/applications/{id}/status` | 企业改投递状态 | — 未测 | |
|
||||
| POST | `/applications/{id}/withdraw` | 撤回投递 | — 未测 | |
|
||||
| GET | `/applications/{id}/timeline` | 状态时间线 | ✅ 正常(id 不存在返回 404) | |
|
||||
|
|
||||
**没有 `GET /applications` 这条路由。** |
|
||||
|
|
||||
### 2. 为什么不是 404 而是 500? |
|
||||
|
|
||||
这是 Spring Boot Controller **路由冲突** 导致的。 |
|
||||
|
|
||||
后端 Controller 大概率写了: |
|
||||
|
|
||||
```java |
|
||||
@RestController |
|
||||
@RequestMapping("/api/v1/applications") |
|
||||
public class ApplicationController { |
|
||||
|
|
||||
@GetMapping("/me") // GET /applications/me → 200 ✓ |
|
||||
public Page<Application> listMine() { ... } |
|
||||
|
|
||||
@GetMapping("/{id}") // GET /applications/{id} → 404(id 不存在)✓ |
|
||||
public Application getById(@PathVariable Long id) { ... } |
|
||||
|
|
||||
@PostMapping("") // POST /applications → 404(jobId 不存在)✓ |
|
||||
public Application create(@RequestBody ...) { ... } |
|
||||
|
|
||||
// 但没有写 @GetMapping("")! |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
当请求 `GET /applications` 进来时: |
|
||||
- Spring 路由解析器在候选路由里找匹配 |
|
||||
- `/applications/me` 需要路径是 `/me`,不匹配 |
|
||||
- **`/{id}` 匹配成功** —— `id` 被解析为字符串 `"applications"` |
|
||||
- 但方法签名上 `@PathVariable Long id` 期望 `Long` 类型 |
|
||||
- Spring 尝试把 `"applications"` 转成 `Long` → **类型转换异常** |
|
||||
- 全局异常处理器没兜住这个异常 → 返回 HTTP 500 `internal server error` |
|
||||
|
|
||||
这是 Spring Boot Controller 常见的路由设计陷阱:**一个 Controller 既有 `/{id}` 又有 `/me`,但忘了写根路径 `@GetMapping("")` 或显式拒绝根路径的 GET 请求**。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 修复方案(后端二选一) |
|
||||
|
|
||||
### 方案 A:显式拦截并返回 404(推荐,防御性编程) |
|
||||
|
|
||||
```java |
|
||||
@GetMapping("") |
|
||||
public ResponseEntity<Void> listAll() { |
|
||||
// applications 没有"列出所有投递"这个语义 |
|
||||
return ResponseEntity.notFound().build(); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 方案 B:确认不需要这个路由,忽略 |
|
||||
|
|
||||
如果后端根本不打算提供 `GET /applications`(全量投递列表——企业端才有意义,而且需要按企业 ID 过滤),那 Spring Boot 默认会 404。但 **当前 500 是路由冲突导致的**,需要清理路由或者统一异常处理。 |
|
||||
|
|
||||
### 方案 C:按角色分发(如果后端真打算提供) |
|
||||
|
|
||||
```java |
|
||||
@GetMapping("") |
|
||||
public ResponseEntity<?> listAll(Authentication auth) { |
|
||||
// STUDENT 应该只查自己的 → 404 引导去 /applications/me |
|
||||
// COMPANY 可以查自己企业下的投递 → 需要 companyId 过滤 |
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( |
|
||||
"code", 404, "message", "这个路由不存在,请使用 /applications/me 或 /applications/{id}" |
|
||||
)); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 其他建议 |
|
||||
|
|
||||
### 1. Spring Boot 的 `/{id}` 路由建议加类型约束 |
|
||||
|
|
||||
```java |
|
||||
// 只匹配数字 ID,排除 "/me" 等子路径 |
|
||||
@GetMapping("/{id:\\d+}") |
|
||||
public Application getById(@PathVariable Long id) { ... } |
|
||||
``` |
|
||||
|
|
||||
这样 `GET /applications` 和 `GET /applications/me` 不会被 `/{id}` 吃掉。 |
|
||||
|
|
||||
### 2. 建议加 Controller 测试 |
|
||||
|
|
||||
```java |
|
||||
@WebMvcTest(ApplicationController.class) |
|
||||
class ApplicationControllerTest { |
|
||||
@Test |
|
||||
void get_root_shouldReturn404() throws Exception { |
|
||||
mockMvc.perform(get("/api/v1/applications")) |
|
||||
.andExpect(status().isNotFound()); |
|
||||
} |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
### 3. 统一异常处理加类型转换兜底 |
|
||||
|
|
||||
```java |
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class) |
|
||||
public ResponseEntity<Map<String, Object>> handleTypeMismatch(MethodArgumentTypeMismatchException e) { |
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( |
|
||||
"code", 404, |
|
||||
"message", "资源不存在" |
|
||||
)); |
|
||||
} |
|
||||
``` |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 给后端同事的话 |
|
||||
|
|
||||
> XX,`GET /applications`(不带 /me)带 token 返回 500 我测出来了,OpenAPI 文档里也没定义这个路由。 |
|
||||
> |
|
||||
> 根因是 Spring 路由冲突:Controller 里有 `GET /applications/{id}` 但没有 `GET /applications`,所以 `"applications"` 这个字符串被当成了 `@PathVariable Long id` 传给方法,类型转换失败抛异常,全局异常处理器没兜住就 500 了。 |
|
||||
> |
|
||||
> 前端这边**不需要这个路由**——学生端调的是 `/applications/me`(正常),投递调的是 `POST /applications`(正常),详情调的是 `GET /applications/{id}`(正常)。所以最简单的修复就是给 Controller 加个 `@GetMapping("")` 返回 404,或者 `/{id}` 加类型约束 `@GetMapping("/{id:\\d+}")` 排除子路径。 |
|
||||
> |
|
||||
> 具体怎么修看你那边代码风格,上面三个方案选一个就行。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 附:其他需要后端补的端点清单 |
|
||||
|
|
||||
(来自全量联调扫描,前端有调用但后端没端点) |
|
||||
|
|
||||
### 🔴 高优先级 |
|
||||
|
|
||||
| 前端路径 | 用途 | 前端函数 | |
|
||||
|---|---|---| |
|
||||
| `GET /jobs/{id}/score` | 岗位综合评分(8 维度雷达图) | `fetchJobScore` | |
|
||||
| `GET /jobs/{id}/salary` | 薪资分布数据 | `fetchJobSalary` | |
|
||||
| `GET /jobs/{id}/reviews` | 面经/岗位评价列表 | `fetchJobReviews` | |
|
||||
| `GET /jobs/{id}/competition-ranking` | 同岗位竞争力排名 | `fetchRankData` | |
|
||||
| `GET /profile/me/career-progress` | 求职进度时间轴 | `fetchJobProgress` | |
|
||||
| `GET /jobs/favorites` | 我的收藏岗位 | `fetchFavorites` | |
|
||||
| `GET /conversations` | 沟通会话列表 | `fetchConversations` | |
|
||||
| `GET /notifications` | 系统通知 | `fetchNotifications` | |
|
||||
|
|
||||
### 🟡 中优先级 |
|
||||
|
|
||||
| 前端路径 | 用途 | 前端函数 | |
|
||||
|---|---|---| |
|
||||
| `GET /jobs/compare` | 岗位对比 | `fetchJobCompare` | |
|
||||
| `GET /offers/compare` | Offer 对比 | `fetchOfferCompare` | |
|
||||
| `GET /profile/me/preferences` | 求职偏好 | `fetchJobPreference` | |
|
||||
| `PUT /profile/me/preferences` | 保存求职偏好 | 更新 | |
|
||||
| `POST /jobs/{id}/reviews` | 发布评价 | `submitReview` | |
|
||||
| `GET /interviews/me` | 面试列表 | `fetchMyInterviews` | |
|
||||
| `GET /interviews/{id}/score` | 面试评分 | `fetchInterviewScore` | |
|
||||
| `GET /company/candidates/{id}` | 候选人详情 | `fetchCandidateDetail` | |
|
||||
|
|
||||
### 🔴 已对齐但后端仍有问题 |
|
||||
|
|
||||
| 端点 | 问题 | |
|
||||
|---|---| |
|
||||
| `POST /auth/sms/send` | `sms sender unavailable`(短信网关未通,无 mock code 旁路) | |
|
||||
| `GET /jobs/search` / `GET /jobs` / `GET /companies/{id}/jobs` | `items: []`(后端无 job seed 数据) | |
|
||||
| `GET /profile/me` | 返回空对象 `{}`(后端未定义 STUDENT profile 字段) | |
|
||||
| `GET /companies/{id}` | 只有 id=1 有数据(企业 seed 不足) | |
|
||||
@ -1,109 +0,0 @@ |
|||||
# 联调路径校正映射表(基于 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,70 @@ |
|||||
|
/** |
||||
|
* 换端(求职者 ⇄ 企业)hook。 |
||||
|
* |
||||
|
* 换端是**服务端操作**,不是本地改一个字段:契约 `POST /auth/identity/select` 负责 |
||||
|
* 决定这个账号到底是什么身份,并(首次)下发新凭证。页面上的「切换到企业端」按钮 |
||||
|
* 早先只调本地 `setRole('enterprise')` 就跳进企业端 —— 账号在服务器眼里仍是求职者, |
||||
|
* 于是接口全部按求职者鉴权,页面却按企业渲染。这里改为调服务端并采用其返回值。 |
||||
|
*/ |
||||
|
import { useCallback, useState } from 'react'; |
||||
|
import { mapBackendRole, selectIdentity, type IdentitySelectionPayload } from '../api/auth'; |
||||
|
import { useAuth } from '../context/AuthContext'; |
||||
|
import type { AppRole } from '../types'; |
||||
|
|
||||
|
/** 换端结果:失败时 message 是可直接展示给用户的原因(来自后端或网络层) */ |
||||
|
export interface IdentitySwitchOutcome { |
||||
|
ok: boolean; |
||||
|
/** 成功后的服务器角色 */ |
||||
|
role: AppRole | null; |
||||
|
/** 仍需完成身份选择(后端 identitySelectionRequired) */ |
||||
|
needsSelection: boolean; |
||||
|
/** 失败原因 */ |
||||
|
message?: string; |
||||
|
} |
||||
|
|
||||
|
export function useIdentitySwitch() { |
||||
|
const { applySession } = useAuth(); |
||||
|
const [switching, setSwitching] = useState(false); |
||||
|
const [error, setError] = useState<Error | null>(null); |
||||
|
|
||||
|
const switchTo = useCallback( |
||||
|
async ( |
||||
|
identity: 'STUDENT' | 'COMPANY', |
||||
|
/** 选 COMPANY 时必填(契约要求);其余情况不要传 */ |
||||
|
company?: IdentitySelectionPayload['company'], |
||||
|
): Promise<IdentitySwitchOutcome> => { |
||||
|
setSwitching(true); |
||||
|
setError(null); |
||||
|
try { |
||||
|
const res = await selectIdentity(company ? { identity, company } : { identity }); |
||||
|
/* |
||||
|
* 同身份重复请求时后端只回状态、不重复颁发凭证(契约明写)。 |
||||
|
* 此时 accessToken 为空,不能拿空串去覆盖现有会话 —— 那会把用户踢下线。 |
||||
|
*/ |
||||
|
if (res.accessToken && res.refreshToken) { |
||||
|
applySession({ |
||||
|
accessToken: res.accessToken, |
||||
|
refreshToken: res.refreshToken, |
||||
|
expiresAt: res.expiresAt, |
||||
|
backendRole: res.role, |
||||
|
identitySelectionRequired: res.identitySelectionRequired, |
||||
|
}); |
||||
|
} |
||||
|
return { |
||||
|
ok: !res.identitySelectionRequired, |
||||
|
role: mapBackendRole(res.role), |
||||
|
needsSelection: res.identitySelectionRequired, |
||||
|
}; |
||||
|
} catch (e) { |
||||
|
const err = e instanceof Error ? e : new Error('切换身份失败,请稍后重试'); |
||||
|
setError(err); |
||||
|
return { ok: false, role: null, needsSelection: false, message: err.message }; |
||||
|
} finally { |
||||
|
setSwitching(false); |
||||
|
} |
||||
|
}, |
||||
|
[applySession], |
||||
|
); |
||||
|
|
||||
|
return { switchTo, switching, error }; |
||||
|
} |
||||
@ -0,0 +1,56 @@ |
|||||
|
/** |
||||
|
* 登录成功后的落地判定(验证码 / 密码 / 一键登录三个入口共用)。 |
||||
|
* |
||||
|
* 后端 `POST /auth/login` 的语义是「新手机号创建 UNASSIGNED 账号, |
||||
|
* `identitySelectionRequired=true` 时前端**必须**进入首次身份选择;旧角色保持不变」。 |
||||
|
* 所以「登录成功」并不等于「可以进主界面」,必须先看响应再决定去哪: |
||||
|
* |
||||
|
* - 还需选身份,或服务器角色认不出来(UNASSIGNED / 未知)→ 去身份选择 |
||||
|
* - 服务器给了明确角色 → 直接落地对应端 |
||||
|
* |
||||
|
* 全程不使用本地缓存的角色:本地角色只代表「上次是什么」,不能代表「现在是什么」。 |
||||
|
*/ |
||||
|
import { useCallback } from 'react'; |
||||
|
import { useRouter } from 'expo-router'; |
||||
|
import { mapBackendRole, type AuthLoginData } from '../api/auth'; |
||||
|
import { useAuth } from '../context/AuthContext'; |
||||
|
|
||||
|
/** 登录成功后的去处 */ |
||||
|
export type LandingTarget = 'identity' | 'candidate' | 'enterprise'; |
||||
|
|
||||
|
export function useLoginLanding() { |
||||
|
const router = useRouter(); |
||||
|
const { applySession } = useAuth(); |
||||
|
|
||||
|
/** 写入会话(双 Token + 服务器角色)并给出落地目标 */ |
||||
|
const land = useCallback( |
||||
|
(result: AuthLoginData): LandingTarget => { |
||||
|
applySession({ |
||||
|
accessToken: result.accessToken, |
||||
|
refreshToken: result.refreshToken, |
||||
|
expiresAt: result.expiresAt, |
||||
|
backendRole: result.role, |
||||
|
identitySelectionRequired: result.identitySelectionRequired, |
||||
|
}); |
||||
|
const role = mapBackendRole(result.role); |
||||
|
if (result.identitySelectionRequired || !role) return 'identity'; |
||||
|
return role === 'enterprise' ? 'enterprise' : 'candidate'; |
||||
|
}, |
||||
|
[applySession], |
||||
|
); |
||||
|
|
||||
|
/** |
||||
|
* 执行跳转。 |
||||
|
* @returns true = 已跳转到某一端;false = 目标端未知,调用方需引导去身份选择页 |
||||
|
*/ |
||||
|
const go = useCallback( |
||||
|
(target: LandingTarget): boolean => { |
||||
|
if (target === 'identity') return false; |
||||
|
router.replace(target === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); |
||||
|
return true; |
||||
|
}, |
||||
|
[router], |
||||
|
); |
||||
|
|
||||
|
return { land, go }; |
||||
|
} |
||||
@ -1,26 +1,52 @@ |
|||||
import { useRouter } from 'expo-router'; |
import { useRouter } from 'expo-router'; |
||||
|
import { Alert } from 'react-native'; |
||||
import { useAuth } from '../context/AuthContext'; |
import { useAuth } from '../context/AuthContext'; |
||||
import { useRole } from '../context/RoleContext'; |
|
||||
import type { AppRole } from '../types'; |
import type { AppRole } from '../types'; |
||||
import { useOnboarding } from './useOnboardingFlow'; |
import { useOnboarding } from './useOnboardingFlow'; |
||||
|
|
||||
/** |
/** |
||||
* 注册完成后的真实落地:先把采集到的资料落库,再写入登录态与身份,跳转对应端主界面。 |
* 注册流程收尾:把引导采集到的资料落库,然后按**服务器已确认的身份**落地到对应端。 |
||||
* 真实后端就绪后,login 的 token 替换为接口返回; |
* |
||||
* setRole 用于让 index.tsx 漏斗放行,避免注册完成又被弹回注册。 |
* 这里不再颁发任何令牌。此前实现是 `login('mock-token-' + Date.now())` —— |
||||
|
* 无论用户是谁、后端返回什么角色,收尾时都往本地塞一个假 Token 再跳转, |
||||
|
* 于是「注册完成」在服务端根本没发生过,而本地已自认登录。 |
||||
|
* |
||||
|
* 前置条件由调用方保证:走到收尾页之前必须已经通过 `/auth/login` 建立了会话, |
||||
|
* 并且(新账号)已通过 `/auth/identity/select` 选定身份。缺失时弹错并留在当前页, |
||||
|
* 而不是编一个会话把用户送进主界面 —— 所以本函数**不会 reject**, |
||||
|
* 避免 `void finish(...)` 这种既有调用方式产生未处理的 Promise 拒绝。 |
||||
*/ |
*/ |
||||
export function useRegisterFinish() { |
export function useRegisterFinish() { |
||||
const router = useRouter(); |
const router = useRouter(); |
||||
const { login } = useAuth(); |
const { role, isTokenValid } = useAuth(); |
||||
const { setRole } = useRole(); |
|
||||
const { flushSave } = useOnboarding(); |
const { flushSave } = useOnboarding(); |
||||
|
|
||||
return async (role: AppRole) => { |
return async (expectedRole: AppRole): Promise<void> => { |
||||
// 落地前落库:最后一步(如 B3 的公司名/联系人)不会再有下一次跳转,
|
try { |
||||
// 不在这里提交就会随页面卸载一起丢掉
|
// 落地前落库:最后一步(如 B3 的公司名/联系人)不会再有下一次跳转,
|
||||
await flushSave(); |
// 不在这里提交就会随页面卸载一起丢掉
|
||||
setRole(role); |
await flushSave(); |
||||
login(`mock-token-${Date.now()}`); |
} catch (e) { |
||||
|
Alert.alert('资料保存失败', (e as Error).message || '请检查网络后重试'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!isTokenValid) { |
||||
|
Alert.alert('无法完成注册', '登录状态已失效,请重新登录后再继续'); |
||||
|
return; |
||||
|
} |
||||
|
if (!role) { |
||||
|
// 常见原因:后端返回 UNASSIGNED(还没选身份)却被放进收尾页
|
||||
|
Alert.alert('无法完成注册', '尚未完成身份选择,请先选择你的身份'); |
||||
|
return; |
||||
|
} |
||||
|
if (role !== expectedRole) { |
||||
|
// 用户以为自己注册成了企业,服务器却给出求职者身份 —— 必须暴露而不是照本地口径跳
|
||||
|
Alert.alert('身份不一致', `服务器返回的账号身份是「${role}」,与所选不符,请重新登录确认`); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 角色已是服务器事实,这里只负责跳转,不再写任何会话
|
||||
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); |
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); |
||||
}; |
}; |
||||
} |
} |
||||
|
|||||
@ -1,87 +0,0 @@ |
|||||
# 后端联调 · 前端实现说明 |
|
||||
|
|
||||
> 适用范围:`有职`(youzhi)App 前端对接《职来后端接口联调说明》(`job.youthome.com.cn` 规范) |
|
||||
> 关联文件:`src/api/http.ts`、`src/api/auth.ts`、`src/lib/token-store.ts`、`src/context/AuthContext.tsx`、`src/types/user.ts` |
|
||||
> 本文只记录**前端已落地的地基与契约映射**,完整接口字段以运行时 OpenAPI 为唯一依据(规范 §7)。 |
|
||||
|
|
||||
## 一、本次已落地(地基,不涉及业务端点) |
|
||||
|
|
||||
| 层 | 文件 | 内容 | |
|
||||
|---|---|---| |
|
||||
| 请求层 | `src/api/http.ts` | 统一出口:`Base URL` + `Bearer` 注入 + 信封解析 + **401 单飞刷新** + `X-Request-Id` + 状态码语义 | |
|
||||
| 令牌 | `src/lib/token-store.ts` | `accessToken` + `refreshToken` 内存单例 + AsyncStorage 持久化 | |
|
||||
| 鉴权 | `src/api/auth.ts` | `sendSmsCode` / `loginByPassword` / `loginBySms` / `fetchCurrentUser` / `requestLogout` / `registerCompany` | |
|
||||
| 会话 | `src/context/AuthContext.tsx` | `setSession(access,refresh,expiresAt)`;启动时 `hydrateTokens`;注册 401 清会话回调 | |
|
||||
| 角色 | `src/types/user.ts` + `auth.ts` | `AppRole` 扩 `admin`;`mapBackendRole(STUDENT→candidate / COMPANY→enterprise / ADMIN→admin)` | |
|
||||
| 环境 | `app.config.js` | 默认 `apiBaseUrl = https://job.youthome.com.cn/api/v1`(保留 `API_BASE_URL` 环境变量覆盖) | |
|
||||
| mock 开关 | `app.config.js` + `http.ts` | `extra.apiUseMock`(默认 `true`);`isMockMode()` 决定业务函数走 mock 还是真实请求;联调期置 `false` | |
|
||||
|
|
||||
业务端点按 **mock 开关** 分阶段落地:开发期(`apiUseMock=true`)返回本地数据不触网,联调期(`false`)直连后端。`jobs` / `profile` / `messages` 等 100+ mock 函数**逐个替换**为真实 `request()` 调用,页面与 hook 零改动。 |
|
||||
|
|
||||
## 二、规范条款逐条对照 |
|
||||
|
|
||||
| 规范 | 前端现状 | |
|
||||
|---|---| |
|
||||
| §1 基地址 / 健康检查 / OpenAPI | 基地址已接 `API_BASE_URL`;`checkHealth()` 已实现对 `/health`(UP 判定);OpenAPI 为后端准绳,前端不手抄 | |
|
||||
| §3 信封 `code/message/data/requestId/timestamp` | `request()` 解析 envelope;`code!==200` 抛 `ApiError`;后端 `message` 优先(更具体),无 message 时回退状态码文案 | |
|
||||
| §3 `Authorization: Bearer` | 自动注入;匿名接口(登录/短信/刷新)显式 `auth:false` 不带头 | |
|
||||
| §4 双 Token + `/auth/refresh` | `token-store` 持久化 `access+refresh`;401 触发刷新 | |
|
||||
| §4 一次串行刷新 + 重放 | 模块级 `refreshPromise` 单飞闸门:并发 401 只刷一次,成功后重放原请求 | |
|
||||
| §4 刷新失败清会话 | 清 Token + `unauthorizedHandler`(AuthContext 注册为清登录态) | |
|
||||
| §4 Token 不打印/上报/进 URL | 令牌仅在 `http.ts` 内层使用,错误对象不回吐原始 Token | |
|
||||
| §5 角色以后端 `/auth/me` 为准 | `fetchCurrentUser()` 返回映射后角色;密码登录页(`P2PasswordLogin`)登录后调 `/auth/me` 并 `setRole`,与本地选择不一致时纠正路由 | |
|
||||
| §6 企业注册 | `registerCompany()` 已实现对 `/auth/company/register`;客户端不提交 `userId/role/verified/passwordHash` | |
|
||||
| §8 状态码语义 | `pickHttpMessage` 覆盖 400/401/403/404/409/429/500/503;网络异常降级为 `httpStatus=0` | |
|
||||
| §10 安全 | 不写 Token/验证码/密码到日志或前端 DOM;`X-Request-Id` 用于联调报障 | |
|
||||
|
|
||||
## 三、业务端点转换进度(mock 开关 + 全量 fetch 函数,2026-09-24 收口) |
|
||||
|
|
||||
> 转换规则:保留函数签名与返回类型 `T`(即 AGENTS.md「保留签名、只换函数体」),`if (isMockMode())` 分支保留本地数据,真实分支走 `request<T>(path, { auth, query })`。路径按规范 §7 重映射(旧 `@url` 多带 `/api` 前缀、未带 `/v1`,已校正)。 |
|
||||
|
|
||||
### 3.1 已落地真实端点(2026-09-28 经 OpenAPI 校正) |
|
||||
|
|
||||
> 路径以 `docs/联调路径校正映射.md` 为权威逐函数表;下表仅列本次确认可直连的端点(ALIGNED + WRONG→REAL)。 |
|
||||
|
|
||||
| 函数 | 真实路径 | 鉴权 | 说明 | |
|
||||
|---|---|---|---| |
|
||||
| `fetchJobList` | `GET /jobs` | 否 | 分页信封 `{items}` | |
|
||||
| `fetchJobDetail` | `GET /jobs/{id}` | 否 | 公开岗位详情 | |
|
||||
| `fetchJobPreference` | `GET /profile/me/preferences` | 是 | 原 `/profile/me/job-preference` 已校正 | |
|
||||
| `fetchFavorites` | `GET /jobs/favorites` | 是 | 原 `/profile/me/favorites` 已校正 | |
|
||||
| `fetchJobProgress` | `GET /profile/me/career-progress` | 否 | 原 `/tools/job-progress` 已校正 | |
|
||||
| `fetchEmployeeReviews` | `GET /jobs/{id}/reviews` | 否 | | |
|
||||
| `createJobReview` | `POST /jobs/{id}/reviews` | 是 | body `{content}` | |
|
||||
| `fetchCompareData` | `GET /jobs/compare` | 否 | | |
|
||||
| `fetchUserProfile` | `GET /profile/me` | 是 | | |
|
||||
| `fetchResume` | `GET /profile/me/resume` | 是 | 原 `/resume` 已校正(修复双前缀 bug) | |
|
||||
| `fetchConversations` | `GET /conversations` | 是 | 原 `/messages/conversations` 已校正 | |
|
||||
| `fetchNotifications` | `GET /notifications` | 是 | 原 `/messages/notifications` 已校正 | |
|
||||
| `fetchInterviews` | `GET /interviews/me` | 是 | 原 `/messages/interviews` 已校正 | |
|
||||
| `fetchConversation` | `GET /conversations/{id}` | 是 | 原 `/chat/conversations/{id}` 已校正 | |
|
||||
| `fetchRankData` | `GET /jobs/{jobId}/competition-ranking` | 否 | 原 `/jobs/rank` 已校正(修复 `${jobId}` 未插值 bug) | |
|
||||
| `fetchCandidateDetail` | `GET /company/candidates/{applicationId}` | 是 | 原 `/enterprise/candidates` 已校正 | |
|
||||
| `fetchInterviewScore` | `GET /interviews/{id}/score` | 是 | 原 `/enterprise/interviews` 已校正 | |
|
||||
| `fetchInterviewScoreDraft` | `PUT /interviews/{id}/score` | 是 | 草稿复用评分端点,原 `/enterprise/.../draft` 已校正 | |
|
||||
| `fetchOfferCompareData` | `GET /offers/compare` | 否 | 已对齐 | |
|
||||
|
|
||||
> 写函数 `saveJobPreference`(PUT /profile/me/preferences)已校正路径;其余 `saveResume*` / `saveWeightSettings` / `saveSalaryRecord` 等经审计属「幽灵域」(后端无 OpenAPI 契约),已退回 mock-only。 |
|
||||
|
|
||||
### 3.1.2 幽灵域(本轮批量转换的其余 62 个 fetch 函数,2026-09-28 校正) |
|
||||
|
|
||||
> 经 OpenAPI 审计,这 62 个函数所属域(/growth、/user、/tools、/game、/interview、/evaluation、/enterprise、/legal、/settings、/salary、/operation 等)**后端均无契约**。处理方式:保留 `if(isMockMode())` mock 分支,**真实态不再调用 `request()`(翻真实开关也不触网)**,待后端补齐契约后再接。原占位 `【待确认】` 标记已全部清除。逐函数明细见 `docs/联调路径校正映射.md` §三。 |
|
||||
|
|
||||
### 3.2 待转 / 明确不接后端 |
|
||||
|
|
||||
- **设备本地能力(保持 mock,无后端等价物)**:`fetchCacheSize`(存储占用)、`fetchCarrierPhone`(运营商取号 SDK)、`fetchWechatLoginResult`(微信登录态)、`fetchOnboardingProfile`(本地引导资料唯一真相源)、`fetchOnboardingChecklist`(本地清单派生)、`fetchDeleteAllData`(本地清空)。 |
|
||||
- **非 fetch 写函数(不在本次 fetch 范围,后续接)**:`sendChatMessage`、`saveResume*` 系列、`saveOnboardingProfile`、`toggleFavorite`、`saveJobPreference` 等写操作——需 PUT/POST body 映射 + 保留本地 storage 回退作为真实后端未就绪时的兜底。 |
|
||||
|
|
||||
## 四、明确未做(写入类 + 实时能力,下一阶段) |
|
||||
|
|
||||
- **WebSocket**:`wss://job.youthome.com.cn/api/v1/ws` 未接入(无对应实时功能)。 |
|
||||
- **企业入驻状态机**:`/companies/me/onboarding` 的 `missingFields` / `allowedActions` 驱动 UI 尚未接。 |
|
||||
- **其余入口页角色纠正**:仅密码登录页已接 `/auth/me`;微信登录、验证码登录等入口需在登录后补齐角色权威化。 |
|
||||
- **写入类函数**:见 §3.2 非 fetch 写函数(sendChatMessage / saveResume* / saveOnboardingProfile / toggleFavorite / saveJobPreference 等),需补 body 映射与本地 storage 兜底。 |
|
||||
|
|
||||
## 五、命名待确认(非阻塞) |
|
||||
|
|
||||
- 域名 `youthome.com.cn`(青年家)与 App 品牌「有职 / youzhi」不一致;Storage 前缀仍为历史遗留 `qingsongpin:`,**勿擅自改名**(会导致老用户本地态失效)。 |
|
||||
@ -1,159 +0,0 @@ |
|||||
# 微信登录接入方案(P1) |
|
||||
|
|
||||
> ⚠️ **入口已摘除(2026-09-15,产品决定)** |
|
||||
> 丁总决定前端不做 QQ / 微信登录,现阶段登录方式只保留「手机号 + 短信验证码」与「手机号 + 密码」,以后另加青核登录。 |
|
||||
> **代码与页面完整保留待启用**:`P2W`(扫码)/`B1`(授权)页面、`useWechatLogin`、`useWechatBindPhone`、`wechat-sdk.ts`、`types/wechat-auth.ts`、本文件所述三个接口函数**均未删除**,仅从 `P1Login` 与 `P2PhoneCode` 摘掉了入口。 |
|
||||
> 恢复入口 = 在 `P1Login.tsx` 重新挂上微信按钮与 `useWechatLogin` 状态机 + 在 `P2PhoneCode.tsx` 恢复微信圆标;`app/register.tsx` 的 `P2W` / `B1` 两个 case 一直都在。 |
|
||||
> 本文以下内容描述的是**代码现状与未来接入路线**,不代表当前有可用入口。 |
|
||||
|
|
||||
> 状态:方案文档 · 不涉及代码改写 · 真实后端就绪后按路线图替换 mock 即可 |
|
||||
> 适用范围:轻松聘 App 微信授权登录(三分支状态机) |
|
||||
> 关联文件:`src/lib/wechat-sdk.ts`、`src/hooks/useWechatLogin.ts`、`src/api/api.ts`、`src/types/wechat-auth.ts`、`components/onboarding/screens/P1Login.tsx`、`components/onboarding/screens/P2PhoneCode.tsx` |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 0. 一句话结论 |
|
||||
|
|
||||
前端三分支状态机与页面流转**已全部联通**,当前唯一阻塞点是:微信原生 SDK 未接入(Expo Go 无法跑原生模块)、以及后端两个接口仍是 mock。 |
|
||||
**接入真实微信 = 替换 1 个函数 (`requestWechatAuthCode`) + 替换 2 个接口函数体 (`fetchWechatLoginResult` / `bindWechatAndRegister`) + 申请微信开放平台移动应用 + 用 EAS Build 生成自定义客户端。** 上层 hook、页面、类型**零改动**。 |
|
||||
|
|
||||
> 注:摘除入口后,`P1Login.tsx` 不再引用 `useWechatLogin`,上表「页面流转」一行的起点需改为 `P2W`;恢复入口时按现状挂回即可。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 1. 现状盘点(已实现 vs 待接入) |
|
||||
|
|
||||
| 层 | 文件 | 现状 | 接入动作 | |
|
||||
|---|---|---|---| |
|
||||
| 类型 | `src/types/wechat-auth.ts` | ✅ 已完整定义 | 无需改动 | |
|
||||
| 状态机 | `src/hooks/useWechatLogin.ts` | ✅ 8 态判别联合,已联通 | 无需改动 | |
|
||||
| 页面流转 | `P1Login.tsx` → `P2PhoneCode.tsx` → `P3` | ✅ 已联通 | 无需改动 | |
|
||||
| 绑定 hook | `src/hooks/useWechatBindPhone.ts` | ✅ 已联通 | 无需改动 | |
|
||||
| 登录态 | `src/context/AuthContext.tsx` | ✅ `login(token, expiresAt)` / `isTokenValid` 已就绪 | 无需改动 | |
|
||||
| **SDK 适配层** | `src/lib/wechat-sdk.ts` | ⚠️ **mock**:返回 `mock_wx_code_*` | **替换 `requestWechatAuthCode`** | |
|
||||
| **后端接口** | `src/api/api.ts` | ⚠️ **mock**:`fetchWechatLoginResult` / `bindWechatAndRegister` | **替换函数体** | |
|
||||
| 微信开放平台 | —— | ❌ 未申请移动应用 | 申请 AppID + Universal Links | |
|
||||
| 构建产物 | EAS | ❌ 无自定义客户端 | `eas build --profile development` | |
|
||||
|
|
||||
**已验证的关键事实**(直接取自源码,非推测): |
|
||||
- `useWechatLogin` 的 `start()` 逻辑:`isTokenValid` 为真 → `alreadyLoggedIn`(免授权直接落地);否则 `authenticating` → `requestWechatAuthCode()` → `verifying` → `fetchWechatLoginResult(code)`,按 `result.status` 分流 `active` / `new` / `deleted`。 |
|
||||
- `new` 分支:`setState({ kind: 'needBindPhone', ticket: { token: result.token, openid: result.openid } })` —— **临时凭证已下发并透传**。 |
|
||||
- `P1Login.tsx` 在 `needBindPhone` 时把 `wechatTicket` 写入 `useOnboardingFlow` 的 `wechatTicket` 字段并跳 `P2`。 |
|
||||
- `P2PhoneCode.tsx` 检测到 `wechatTicket` 后调用 `bindWechatAndRegister({ token, openid, phone, code })` 完成注册。 |
|
||||
- `deleted` 分支:不写登录态,仅提示不可登录,**无复活出口**(符合账号注销即不可逆的硬约束)。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 2. 接入目标与成功标准 |
|
||||
|
|
||||
**目标**:让真实微信用户能走通 `new / active / deleted` 三条链路,且: |
|
||||
1. 用户第一次用微信登录 → 跳手机号+验证码 → 注册落地; |
|
||||
2. 老用户再次微信登录 → 直接进主界面(本地 token 有效时甚至免授权); |
|
||||
3. 处于注销审核期的账号 → 登录页明确提示「该账号正在申请注销中,无法登录」,无复活入口。 |
|
||||
|
|
||||
**成功标准(验收口径)**: |
|
||||
- [ ] `requestWechatAuthCode()` 真正调起微信并回传一次性 `code`(非 mock)。 |
|
||||
- [ ] 后端 `/api/auth/wechat` 用 code 换 openid → 判定 `new/active/deleted` → 首响下发可复用临时凭证。 |
|
||||
- [ ] `bind-phone` 用临时凭证(非 code)完成 openid 与手机号绑定注册。 |
|
||||
- [ ] iOS 能在微信授权后通过 Universal Links 正确回调 App;Android 能通过包名+签名回调。 |
|
||||
- [ ] 三分支在自定义客户端(非 Expo Go)上全部走通。 |
|
||||
- [ ] mock 辅助 `MOCK_WECHAT_STATUS` / `setMockWechatAccountStatus` 已删除,页面无任何 mock 分支残留。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 3. 两个硬约束(设计已遵守,接后端不可破坏) |
|
||||
|
|
||||
1. **微信 code 一次性(5 分钟有效)**:用户授权后原 code 已被首次请求消耗,绑定手机号时**拿不到第二个 code**。因此后端必须在首次响应里下发 `WechatPendingTicket`(含 `token` + `openid`),前端在 `P2` 回传票据而非 code。 |
|
||||
- 类型已建模:`WechatPendingTicket { token: string; openid: string }`(`src/types/wechat-auth.ts:29`)。 |
|
||||
2. **注销即不可登录、不可复活**:`deleted` 分支前端不写登录态、不提供任何复活入口;后端需保证审核期内账号返回 `deleted` 且拒绝任何登录/复活请求。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 4. 接入路线图(按执行顺序) |
|
||||
|
|
||||
### Step 1 · 微信开放平台申请移动应用(前置,阻塞项) |
|
||||
- 登录微信开放平台 → 创建**移动应用**,拿到 `AppID` / `AppSecret`。 |
|
||||
- **iOS**:登记 **Universal Links**(微信授权回调硬性要求)。需准备一个 HTTPS 域名 + 后端托管 `apple-app-site-association`。 |
|
||||
- **Android**:登记 **应用包名**(`com.qingsongpin.app`,见 `app.config.js`)与**应用签名**(发布/调试签名 SHA256)。 |
|
||||
- ⚠️ 此步产生的 `AppID`、`Universal Links` 域名需回填到代码与 EAS 配置(见 Step 3/4)。 |
|
||||
|
|
||||
### Step 2 · 安装微信 SDK(Expo 插件优先) |
|
||||
```bash |
|
||||
npx expo install react-native-wechat-lib |
|
||||
# 或选用维护更活跃的 expo 兼容插件(以官方 docs 为准) |
|
||||
``` |
|
||||
- 原生模块需在 `ios/` `android/` 原生壳中注册(Expo 前置预构建会处理 config plugin 注入,无需手改原生代码)。 |
|
||||
|
|
||||
### Step 3 · 生成 EAS 自定义开发客户端(Expo Go 跑不了原生模块) |
|
||||
- 当前 `eas.json` 已有 `development` profile(`developmentClient: true`)。 |
|
||||
- 执行: |
|
||||
```bash |
|
||||
eas build --profile development --platform ios |
|
||||
eas build --profile development --platform android |
|
||||
``` |
|
||||
- 安装产物到真机/模拟器。**后续所有真机验证都在此客户端中进行,Expo Go 无法调起微信。** |
|
||||
|
|
||||
### Step 4 · 替换 SDK 适配层(唯一改动点 · 前端) |
|
||||
文件:`src/lib/wechat-sdk.ts` |
|
||||
- 顶部 `import WeChat from 'react-native-wechat-lib';` |
|
||||
- `isWechatAvailable()` 改为 `return WeChat.isWXAppInstalled();` |
|
||||
- `requestWechatAuthCode()` 替换为: |
|
||||
```ts |
|
||||
const res = await WeChat.sendAuthRequest('snsapi_userinfo', 'wechat_auth_state'); |
|
||||
return res.code; |
|
||||
``` |
|
||||
- `app.config.js` 的 `extra` 中补 `wechatAppId`,在 SDK 初始化时读取(或走 `expo-config-plugin` 注入原生 Info.plist / AndroidManifest)。 |
|
||||
- **此文件改动不影响任何上层调用方**(签名不变)。 |
|
||||
|
|
||||
### Step 5 · 实现后端两个接口 |
|
||||
| 接口 | 方法 | 职责 | 关键返回 | |
|
||||
|---|---|---|---| |
|
||||
| `/api/auth/wechat` | POST | `code → access_token + openid → 查库判定状态` | `WechatLoginResult`:`new` 时下发明文 `WechatPendingTicket`;`active` 下发正式 token + `expiresAt` + `profile` + `role`;`deleted` 仅 `openid` | |
|
||||
| `/api/auth/wechat/bind-phone` | POST | 用临时凭证 + 手机号 + 验证码完成注册,关联 openid | `WechatSession`:`token` + `expiresAt` + `profile` + `role` | |
|
||||
|
|
||||
- 后端须保证:`code` 一次性;临时凭证有独立 TTL 且可校验来源;`deleted` 账号拒绝登录。 |
|
||||
- `WechatLoginResult` / `WechatSession` 字段契约见 `src/types/wechat-auth.ts`,前后端以此对齐。 |
|
||||
|
|
||||
### Step 6 · 替换 API 层函数体(后端就绪后) |
|
||||
文件:`src/api/api.ts` |
|
||||
- `fetchWechatLoginResult(code)`:删掉 `MOCK_WECHAT_STATUS` 分支,改为 `fetch(...)`,请求 `/api/auth/wechat`,返回 `WechatLoginResult`。函数签名**不变**。 |
|
||||
- `bindWechatAndRegister(payload)`:改为请求 `/api/auth/wechat/bind-phone`,返回 `WechatSession`。签名**不变**。 |
|
||||
- 保留 JSDoc 的 `@method` / `@url` / `@param` / `@returns` 注释。 |
|
||||
|
|
||||
### Step 7 · 清除 mock 残留 |
|
||||
- 删除 `src/api/api.ts` 中的 `let MOCK_WECHAT_STATUS`、`setMockWechatAccountStatus()` 及其导出(同步从 `src/api/index.ts` 移除导出)。 |
|
||||
- 删除 `P1Login.tsx` / 测试代码中调用 `setMockWechatAccountStatus` 的分支(若有)。 |
|
||||
|
|
||||
### Step 8 · 回调与票据闭环验证 |
|
||||
- **iOS Universal Links**:确认微信开放平台登记的 Universal Links 能打开 App,且 `useWechatLogin` 未被重复触发(`alreadyLoggedIn` 短路逻辑生效)。 |
|
||||
- **Android 包名/签名**:确认微信回调能正确唤起 App 并继续 `verifying`。 |
|
||||
- 验证 `needBindPhone` → `wechatTicket` 透传 → `P2` 调用 `bindWechatAndRegister` 全程用的是**临时凭证 token**,而非已失效的 code。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 5. 手动验收路径(可复现) |
|
||||
|
|
||||
1. 在自定义客户端(非 Expo Go)上安装并登录。 |
|
||||
2. **active 分支**:用老微信账号登录 → 期望直接进主界面;杀进程重进(本地 token 有效)→ 期望 `alreadyLoggedIn` 免授权落地。 |
|
||||
3. **new 分支**:用全新微信账号登录 → 期望跳 `P2` 手机号+验证码 → 注册成功进 `P3` 选身份。 |
|
||||
4. **deleted 分支**:用处于注销审核期的账号登录 → 期望登录页提示「该账号正在申请注销中,无法登录」,无复活入口。 |
|
||||
5. 断网 / 取消授权 / 未装微信 → 期望 `error` 态并显示对应提示。 |
|
||||
6. 回归 `tsc --noEmit` 与 `eslint .` 均 0 错误、0 警告。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 6. 风险与待确认项 |
|
||||
|
|
||||
| 项 | 风险/阻塞 | 处置 | |
|
||||
|---|---|---| |
|
||||
| `AppID` / Universal Links | 需用户到微信开放平台申请,非代码可解 | Step 1 前置,未拿到前无法真机验证 | |
|
||||
| react-native-wechat-lib 维护状态 | 库可能滞后于新架构 | 接入时以 Expo 官方 docs 选型为准(AGENTS.md 要求先读 `docs.expo.dev/versions/v57.0.0/`) | |
|
||||
| Universal Links 配置 | iOS 回调硬性要求,易踩坑 | 后端托管 `apple-app-site-association` + 真机验证 | |
|
||||
| 自定义客户端构建 | 首次 EAS Build 耗时较长 | Step 3 提前跑,避免阻塞联调 | |
|
||||
| 临时凭证安全 | code 一次性,凭证必须独立加密 + 短 TTL | 后端实现时强制 | |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 7. 一句话给执行者 |
|
||||
|
|
||||
**别动 hook、页面、类型。** 只动两处:`src/lib/wechat-sdk.ts`(换真 SDK 调用)和 `src/api/api.ts`(换真接口),配齐微信开放平台移动应用 + EAS 自定义客户端,三分支即可真机走通。 |
|
||||
@ -1,138 +0,0 @@ |
|||||
# 职来(有职)前端 · 已转换接口(已"调过")存在的问题 |
|
||||
|
|
||||
> 生成日期:2026-09-28 |
|
||||
> 范围界定:**本文只讨论已在代码中实现真实分支(即已转换为直连后端、存在 `request()` 调用)的接口**存在的问题。 |
|
||||
> 不纳入:纯 mock 接口(`sendChatMessage`、`loginByPhone`、`saveOnboardingProfile`、`fetchOnboardingProfile`、设备本地能力 `fetchCacheSize` / `clearAppCache` / `fetchCarrierPhone` / `fetchWechatLoginResult` 等);明确尚未启动的 WebSocket、企业入驻状态机。 |
|
||||
> 说明:地基(统一请求层、双 Token、401 刷新、角色映射、健康检查)实现正确,不在本文问题范围内。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 零、进展更新(2026-09-28 经 OpenAPI 实机校正) |
|
||||
|
|
||||
> 本轮以 `https://job.youthome.com.cn/api/v1/v3/api-docs`(OpenAPI 3.1.0,182 路径 / 220 path-method)为唯一准绳,对 `api.ts` 全部 `request()` 函数做了实机验证(测试号 `19900009021` 登录取 token 后逐一打真实路径,路径返回业务错误码 6001/7001/8002/8305 等即视为「路径存在、契约待对齐」,网关 404 视为「路径不存在」)。 |
|
||||
|
|
||||
### 审计结论(89 个 request 函数) |
|
||||
|
|
||||
- **ALIGNED(6)**:路径与 OpenAPI 完全一致,翻开关即直连。 |
|
||||
- **WRONG→REAL(14)**:路径前端猜错但有真实等价端点,已改路径(例:`/messages/conversations`→`/conversations`、`/resume`→`/profile/me/resume`、`/jobs/rank`→`/jobs/{id}/competition-ranking`、`/enterprise/candidates`→`/company/candidates`、`/enterprise/interviews`→`/interviews`)。 |
|
||||
- **PHANTOM(69)**:所属域后端无 OpenAPI 契约,翻开关必 404。已**退回 mock-only**(真实态不再调用 `request`,翻真实开关也不触网),待后端补齐契约再接。 |
|
||||
|
|
||||
### 标记清理 |
|
||||
|
|
||||
- `【待确认】` 标记:**0 处**(全部清除,含 `【待确认:…】` 形式)。 |
|
||||
- 实机验证:ALIGNED + WRONG→REAL 共 ~20 个接口真实路径均返回业务错误码(非网关 404),证明路径正确。 |
|
||||
|
|
||||
### 问题状态总览 |
|
||||
|
|
||||
| 原编号 | 状态 | 说明 | |
|
||||
|---|---|---| |
|
||||
| P0-1 | ✅ 已解决 | 89 函数已按 OpenAPI 校正并分类(ALIGNED 6 / WRONG→REAL 14 / PHANTOM 69) | |
|
||||
| P0-2 | ✅ 已解决 | 4 个桥接测试改为断言真实路径;幽灵域真实态断言已删 | |
|
||||
| P1-1 | ✅ 已解决 | `后端联调前端实现.md` §3 已按代码现状重标 | |
|
||||
| P1-2 | ⏳ 待办 | Token `expiresIn` 仍未处理(见下「遗留 P1-2」) | |
|
||||
| P1-3 | ⏳ 待办 | `/auth/me` 角色权威化仍仅在密码登录入口 | |
|
||||
| P1-4 | 🔶 部分 | 幽灵域写函数已退回 mock;已对齐写函数 body 形态待统一 | |
|
||||
| P2-1 | ⏳ 待办 | 145 处 JSDoc `@url /api` 旧前缀未清 | |
|
||||
| P2-2 | ✅ 已解决 | 3 个列表函数真实态从 Spring Page `{items}` 信封取数(实机验证);`/conversations` 真实返回数组已确认不变 | |
|
||||
| P2-3 | ⏳ 待办 | 双真相源切换策略未定 | |
|
||||
| P2-4 | ✅ 缓解 | 默认仍 mock;现幽灵域翻开关也不触网 | |
|
||||
|
|
||||
### 遗留 P1-2(Token 有效期) |
|
||||
|
|
||||
后端登录返回 `expiresIn`(秒数)而非 `expiresAt`;前端 `AuthContext` 仍用 `TOKEN_TTL_MS` 兜底成假 TTL。本轮未改,需单独排期修。 |
|
||||
|
|
||||
### P2-2 分页信封差异(已修复 · 2026-09-28 实机验证) |
|
||||
|
|
||||
**纠正前误述**:此前判断"前端 mock 用 `{content}`、真实用 `{items}`"——实际探查证明前端根本没有按 `.content` 取数的分页信封,mock 返回的是 `{jobs,total}`(fetchJobList)或直接数组,Hook 也不读 `.content`。 |
|
||||
|
|
||||
**真实风险落点**(测试号登录取 token 后逐一打真实路径,看 `data` 结构): |
|
||||
| 接口 | 真实返回(`data`) | 前端原假设 | 结果 | |
|
||||
|---|---|---|---| |
|
||||
| `/conversations` | **数组** | `Conversation[]` | 正确,未改 | |
|
||||
| `/notifications` | **Page 信封** `{total,size,totalPages,page,items}` | `Notification[]` | 空白 | |
|
||||
| `/interviews/me` | **Page 信封** | `Interview[]` | 空白 | |
|
||||
| `/jobs/favorites` | **Page 信封** | `Job[]` | 空白 | |
|
||||
| `/jobs`(fetchJobList) | Page 信封 | — | 早已适配 `data.items` | |
|
||||
|
|
||||
**修复**:`fetchNotifications` / `fetchInterviews` / `fetchFavorites` 真实态改为 `request<{items:T[]}>` 后取 `res.items ?? []`,与 `fetchJobList` 一致;3 个真实态测试同步改为断言从信封解包。tsc 0 错误,全量 jest 495 通过。 |
|
||||
|
|
||||
**延伸待验证(同类型)**:`fetchEmployeeReviews`(`/jobs/{id}/reviews`)同为后端列表接口,实机因测试号无该 job 返回 7001,未取到成功样本;OpenAPI 对该接口 200 schema 为空 `{}`,无法判定形态,先标记待验证,不臆改。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 一、结论 |
|
||||
|
|
||||
- 已转换接口在代码层面"连接"基本铺完:`api.ts` 内 85 个 `fetch*` 读函数 + 大量 `save*/submit*/update*` 写函数**都已带有真实分支**(`if (!isMockMode()) return request(...)`),且全部受 `isMockMode()` 保护,默认 mock 态不会误触网。 |
|
||||
- **但契约未经后端校验**:全文件 **65 处 `【待确认】`** 标记真实路径为前端推测;**157 处 JSDoc `@url` 仍写旧 `/api` 前缀**(实际 `request()` 已去 `/api`,两者不一致)。 |
|
||||
- **现有测试锁定的是推测路径而非真实契约**,制造"绿灯已通过"的错觉。 |
|
||||
- **文档与代码脱节**:`后端联调前端实现.md` §3.2 把已转换的 `saveResume*`、`saveJobPreference` 标为"未接后端 / 后续接",与代码现状不符。 |
|
||||
|
|
||||
> 一句话:**这些接口"代码连了、契约没对"。** 当前不能宣布"已联调完成"。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 二、问题清单(仅已转换接口) |
|
||||
|
|
||||
### P0 — 翻开关即爆 |
|
||||
|
|
||||
| # | 问题 | 证据 | 影响 | |
|
||||
|---|---|---|---| |
|
||||
| P0-1 | **【已解决 2026-09-28】** 原 65 个已转换接口真实路径为前端猜测(`【待确认】`),未对照 OpenAPI 校正 | 现 `【待确认】` 0 处;89 函数已分类 ALIGNED 6 / WRONG→REAL 14 / PHANTOM 69 | 翻开关后 ALIGNED+WRONG→REAL 共 ~20 接口直连真实后端(返回业务码非 404);PHANTOM 69 已退回 mock-only 不触网 | |
|
||||
| P0-2 | **【已解决 2026-09-28】** 测试锁定的是推测路径,不是真实契约 | `backend-bridge-remaining.test.ts` 等 4 文件已改为断言经 OpenAPI 确认的真实路径;幽灵域真实态断言已删除 | CI 绿灯现对应真实契约;81/81 桥接测试通过 | |
|
||||
|
|
||||
### P1 — 必须修,否则已转换接口不可信 |
|
||||
|
|
||||
| # | 问题 | 证据 | 影响 | |
|
||||
|---|---|---|---| |
|
||||
| P1-1 | **文档与代码脱节**:已转换的 `saveResume*`、`saveJobPreference` 被误标"未接后端" | 代码 3352/3369/3387/3413/3426/3439/3459/8684 等均有 `request(...)` 真实分支 | 状态失真,排期与验收失去依据 | |
|
||||
| P1-2 | **Token 有效期由前端臆造**:后端不返回 `expiresAt` 时,`AuthContext` 用 `TOKEN_TTL_MS` 兜底成假 7 天 TTL | `token-store.ts` 注释"后端可能不返回,故可选";`AuthContext.tsx:161/173` `expiresAt ?? Date.now()+TOKEN_TTL_MS`;`isTokenValid` 据此判断 | 影响所有已转换的鉴权请求:后端短过期时前端以为有效、长期不主动刷新,只能靠 401 兜底;或前端假 TTL 过期后误判登出 | |
|
||||
| P1-3 | **角色权威化接口 `/auth/me` 已转换,但只在密码登录入口调用** | 仅 `P2PasswordLogin.tsx:53` 调 `fetchCurrentUser`;微信登录 / 验证码登录 / 注册完成(`useRegisterFinish`)均用本地角色,不调 `/auth/me` | 多入口登录后角色可能与后端不一致,路由纠正逻辑仅在密码入口生效 | |
|
||||
| P1-4 | **已转换写接口 body 形态不一致**:有的包 `{skills}`、有的裸传 `items`、有的包 `{items}` | `saveResumeSkills` → `{ skills }`(3387);`saveResumeExperience` → `items`(3426,未包裹);`saveResumeEducation` → `{ items }`(3413) | 后端若要求统一 envelope,部分已转换写接口会 400 / 字段丢失 | |
|
||||
|
|
||||
### P2 — 隐患 / 卫生 |
|
||||
|
|
||||
| # | 问题 | 证据 | 影响 | |
|
||||
|---|---|---|---| |
|
||||
| P2-1 | **157 处 JSDoc `@url` 仍写旧 `/api` 前缀**,与实际 `request()` 路径(已去 `/api`)不一致 | `grep @url /api` = 157;实际 `request('/jobs')` 无 `/api` | 维护者按注释拼地址必错;JSDoc 不能作契约依据 | |
|
||||
| P2-2 | **✅ 已修复(实机验证)** `fetchNotifications`/`fetchInterviews`/`fetchFavorites` 真实态从 Spring Page `{items}` 信封取数;`/conversations` 真实返回数组已确认 | 实机打 `/notifications`/`/interviews/me`/`/jobs/favorites` 返回 `{total,size,totalPages,page,items}`;原代码假设裸数组会空白 | 翻 `apiUseMock=false` 后消息/收藏/面试列表正常渲染;`fetchEmployeeReviews` 同类待验证 | |
|
||||
| P2-3 | **mock / 真实双真相源切换一致性**:mock 态 `saveResumeSnapshot` 写本地 storage 覆盖层、`fetchResume` 读本地;真实态写后端、读后端 | `saveResumeSnapshot`(mock 写 storage) vs `fetchResume`(真实读后端) | 同一设备从 mock 翻真实后,本地已填资料不出现在后端返回中,需明确迁移/清空策略 | |
|
||||
| P2-4 | **`apiUseMock` 默认 `true` 的隐患**:容易被误判为"联调完成"而翻开关 | `http.ts:31` 默认 `true` | 翻开关即触发 P0-1 的 65 个错误请求 | |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 三、逐域未确认路径明细(P0-1 附录) |
|
||||
|
|
||||
> 下列域的已转换接口真实路径均带 `【待确认】`,为前端推测,未经 OpenAPI 校正。 |
|
||||
|
|
||||
| 域 | 代表函数 | 推测路径 | |
|
||||
|---|---|---| |
|
||||
| 简历写 / 读 | `saveResumeBasicInfo` / `fetchResume` | `/resume/*`、`/resume/save` | |
|
||||
| 消息 / 沟通 | `fetchConversations` / `fetchConversation` / `fetchMatchResult` | `/messages/*`、`/chat/conversations/{id}` | |
|
||||
| 求职者资料 / 偏好 | `fetchUserProfile` / `saveJobPreference` | `/profile/me`、`/profile/me/job-preference` | |
|
||||
| 薪资 / 工具 | `fetchSalaryRecords` / `fetchWeightSettings` | `/salary/records`、`/jobs/weight-settings`、`/tools/*` | |
|
||||
| 测评 / 成长 | `fetchHollandAssessment` / `fetchSkillCloud` | `/growth/*`、`/user/*`、`/tools/*`、`/game/*` | |
|
||||
| 企业端 | `fetchJobManagement` / `fetchEnterpriseOverview` | `/enterprise/*` | |
|
||||
| 法律(阿布阿诺) | `fetchLegalBrand` / `fetchLegalOrders` | `/legal/*` | |
|
||||
| 隐私 / 设置 / 账户 | `fetchPrivacySettings` / `fetchAccountCancel` | `/settings/*`、`/user/*`、`/operation/*` | |
|
||||
|
|
||||
完整 65 处见 `api.ts` 内 `【待确认】` 注释;精确清单应回到 `src/__tests__/backend-bridge-remaining.test.ts` 的 `FUNCS` 数组对照 OpenAPI 逐一校正。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 四、整改建议(针对已转换接口) |
|
||||
|
|
||||
1. **校正契约(P0)**:以《职来后端接口联调说明》`.docx`(§7 OpenAPI,`job.youthome.com.cn`)为唯一准绳,逐函数对照 65 个 `【待确认】` 路径,命中后删注释;**未对照前不宣布"已收口"**。 |
|
||||
2. **让测试服务于真实契约(P0)**:把 `FUNCS` 数组路径从"占位"替换为经 OpenAPI 确认的路径;新增对拍用例(请求路径 + 响应字段 schema 校验),而非只断言前端猜的地址。 |
|
||||
3. **统一已转换写接口 body 形态(P1-4)**:约定"整表替换类用 `{ items }`、单对象类用对象本体",`saveResumeExperience` 当前裸传 `items` 需包裹。 |
|
||||
4. **修正 Token 有效期语义(P1-2)**:后端不返回 `expiresAt` 时不要用 `TOKEN_TTL_MS` 兜底成假 TTL;改为"不预判、靠 401 触发刷新 + 可选短轮询",或强制后端下发 `expiresAt`。 |
|
||||
5. **补全 `/auth/me` 角色权威化(P1-3)**:微信登录 / 验证码登录 / 注册完成三个入口登录后统一调 `/auth/me` 并 `setRole`,与密码入口一致。 |
|
||||
6. **清理 JSDoc(P2-1)**:批量清除 157 处 `@url /api` 旧前缀。 |
|
||||
7. **明确双真相源切换策略(P2-3)**:在登录流程或"清除缓存"中,翻真实态前提示并清空本地覆盖层,避免数据幽灵。 |
|
||||
8. **重写状态文档(P1-1)**:`后端联调前端实现.md` 按代码现状重标已转换接口,删除"未接后端"的误述。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 五、验收口径(建议写入 AGENTS.md) |
|
||||
|
|
||||
> **"接口已联调完成"的定义应为**:① 路径经 OpenAPI 校正并删除 `【待确认】`;② 至少一次真实联调(`apiUseMock=false`)返回符合类型定义的响应;③ 测试断言的是真实路径 + 响应字段。 |
|
||||
> 仅凭"代码里写了 `request()`"或"测试命中占位路径"**不算完成**。 |
|
||||
Binary file not shown.
@ -1,167 +0,0 @@ |
|||||
# 运营商一键登录接入方案(P2Q) |
|
||||
|
|
||||
> 状态:方案文档 · 真实取号 SDK 就绪后按路线图替换 mock 即可 |
|
||||
> 适用范围:轻松聘 App 运营商一键登录(本机号码认证) |
|
||||
> 关联文件:`src/types/carrier-auth.ts`、`src/lib/carrier-sdk.ts`(取号适配层)、`src/hooks/useCarrierPhone.ts`、`src/hooks/useLogin.ts`、`src/api/api.ts`、`components/onboarding/screens/P2QuickLogin.tsx`、`src/__tests__/carrier-login.test.ts`、`src/__tests__/carrier-sdk.test.ts` |
|
||||
> 参考:https://www.cnblogs.com/Frankada/p/15689036.html (本机号码一键登录原理:三大运营商能力边界 + 取号/校验方法) |
|
||||
> 本文回答的问题:「一键登录时应当获取到手机号,为什么获取不到」—— 见第 1 节。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 0. 一句话结论 |
|
||||
|
|
||||
**在本机(Expo Go / 模拟器)跑,取号必然是假的**:本机号码只能由运营商的号码认证 SDK 在端上取回,它是原生模块,Expo Go 内不存在,模拟器也没有 SIM 卡。当前 `fetchCarrierPhone()` 走的是 `src/lib/carrier-sdk.ts` 的 mock 分支,返回写死的号码 `15000009367`(掩码 `150****9367`),用于把**页面链路**跑通。 |
|
||||
**接入真实取号 = 端上换 1 个适配层文件(`src/lib/carrier-sdk.ts` 的 5 个函数体) + 服务器换 1 个接口(`/api/auth/login-by-carrier`) + 用 EAS Build 生成自定义客户端。** 页面、hook、类型、`api.ts` **零改动**。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 1. 原先「取不到手机号」的两个真实原因(已修) |
|
||||
|
|
||||
排查结论:**不是取号 SDK 的问题,是页面根本没读取号结果**。修复前 `P2QuickLogin.tsx` 有两个独立缺陷,叠加起来表现为「号码永远显示占位」: |
|
||||
|
|
||||
| # | 缺陷 | 表现 | 现状态 | |
|
||||
|---|---|---|---| |
|
||||
| 1 | 页面读的是 `data.phone` —— 该字段**只有短信登录通道(P2)才会写入** | 走一键登录进来时 `data.phone` 恒为空 → 号码位永远渲染占位文案「本机号码」,用户以为没取到号 | ✅ 已改为读 `useCarrierPhone()` 的 `maskedPhone` | |
|
||||
| 2 | `loginByCarrier()` 返回的 `phone` **被直接丢弃** | 即使后端换了真号码,页面与后续步骤(P2P/P2R 预填、B3 联系人电话)也拿不到 | ✅ 成功登录后 `setData({ phone: result.phone })` 写回引导资料 | |
|
||||
|
|
||||
顺带补上的**失败分支**(此前不存在,取号失败也照样显示号码、按钮照样可点): |
|
||||
|
|
||||
- `carrierToken` 为空 = 没取到号 → 「一键登录」置灰(空令牌调登录接口必然失败,不如不让点); |
|
||||
- 号码位改为「正在获取本机号码…」/「未能获取本机号码」,并给出**重新获取**与**改用验证码登录**两个出口; |
|
||||
- 取号是**进页面即预取**(`useCarrierPhone` → `useAsyncData`,带自动重试与请求去重),不是等用户点按钮才去取。 |
|
||||
|
|
||||
> 没修的(有意保留):`P2QuickLogin` 的《xx认证服务条款》**不冒充能打开**——该文档由运营商在认证页内提供,本仓库没有,故只弹一段如实说明;页脚服务热线为空时整块不渲染(`SUPPORT_HOTLINE` 为空)。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 2. 现状盘点(已实现 vs 待接入) |
|
||||
|
|
||||
| 层 | 文件 | 现状 | 接入动作 | |
|
||||
|---|---|---|---| |
|
||||
| 类型 | `src/types/carrier-auth.ts` | ✅ `CarrierPhoneResult` / `CarrierName` / `CarrierFailureReason` / `CarrierNetworkInfo` 已定义 | 无需改动 | |
|
||||
| 取号 hook | `src/hooks/useCarrierPhone.ts` | ✅ loading / error / refresh / failureReason 已就绪 | 无需改动 | |
|
||||
| 页面流转 | `P2QuickLogin.tsx` | ✅ 取号 → 换登录态 → 写登录态 → 写回号码 → `P3` | 无需改动 | |
|
||||
| 登录态 | `src/context/AuthContext.tsx` | ✅ `login(token, expiresAt)` 已就绪 | 无需改动 | |
|
||||
| **端上取号适配层** | `src/lib/carrier-sdk.ts` | ✅ 厂商无关适配层已就位(对齐 `wechat-sdk.ts`),现为 mock | **换 SDK 调用(端上)** | |
|
||||
| **取号接口** | `src/api/api.ts` · `fetchCarrierPhone()` | ✅ 已改为调适配层,只做超时/归一化/脱敏 | 无需改动(改适配层即可) | |
|
||||
| **换登录态** | `src/api/api.ts` · `loginByCarrier()` | ⚠️ **mock**:直接下发 `mock_token_carrier_*` | **换真实接口** | |
|
||||
| 运营商/聚合服务 | —— | ❌ 未开通 | 开通号码认证服务 | |
|
||||
| 构建产物 | EAS | ❌ 无自定义客户端 | `eas build --profile development` | |
|
||||
|
|
||||
**已验证的关键事实**(直接取自源码,非推测): |
|
||||
|
|
||||
- `loginByCarrier('')` **直接 reject**(`本机号码未取到,请重新获取或改用验证码登录`),页面因此不能靠空令牌换到登录态。 |
|
||||
- 登录成功后 `writeAuth(result.token)` **确实写入登录态**(此前该页只 `navigate('P3')` 不写 token,是本项目曾存在的严重缺口,已于 2026-09-15 一并修复)。 |
|
||||
- 号码的**唯一真相源是后端换取结果**,不是端上预取的号码:`loginByCarrier` 返回的 `phone` 由接口层给出,页面不自己拼预取到的号码写资料。 |
|
||||
- 取号令牌**一次性且 5 分钟内有效**(`CARRIER_TOKEN_TTL_MS`),过期需重新取号。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 3. 两个硬约束(设计已遵守,接后端不可破坏) |
|
||||
|
|
||||
1. **端上取号 ≠ 可信号码**:运营商 SDK 在端上取回的号码可以被改机工具伪造,因此**可信号码必须由后端拿令牌向运营商换取**(`/api/auth/login-by-carrier` 的返回为准)。前端只把取到的号码拿去展示。 |
|
||||
2. **令牌一次性 + 短 TTL**:`carrierToken` 用后即废、且 5 分钟过期。所以「取号」与「换登录态」必须紧挨着(页面级),不能缓存起来留到下个页面用;过期后唯一的正确动作是**重新取号**。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 4. 接入路线图(按执行顺序) |
|
||||
|
|
||||
### Step 1 · 开通运营商号码认证服务(前置,阻塞项) |
|
||||
- 任选其一:中国移动 / 联通 / 电信官方号码认证能力,或**聚合方案**(阿里云号码认证服务、极光、闪验等,一次接多家运营商)。 |
|
||||
- 需要准备:企业资质、`android.package`(`com.qingsongpin.app`,见 `app.config.js`)与**签名 SHA256**、iOS `bundleIdentifier`。 |
|
||||
- 拿到的 **AppKey / AppSecret** 需同时配置在**端上(SDK 初始化)**与**服务器(换取号码)**两侧。 |
|
||||
|
|
||||
### Step 2 · 安装取号 SDK(Expo 插件优先) |
|
||||
```bash |
|
||||
npx expo install <所选方案的 RN 插件> |
|
||||
``` |
|
||||
- 原生模块需在 `ios/` `android/` 原生壳中注册;有 config plugin 的方案由预构建自动注入,无需手改原生代码。 |
|
||||
- **Expo Go 一定跑不了取号**(`requireNativeModule` 在 import 期执行,缺模块会直接崩),所以本地开发期保留 mock 分支,用 `__DEV__` 或环境变量区分。 |
|
||||
|
|
||||
### Step 3 · 生成 EAS 自定义开发客户端 |
|
||||
```bash |
|
||||
eas build --profile development --platform android |
|
||||
eas build --profile development --platform ios |
|
||||
``` |
|
||||
- 与微信登录同一前提(见 `微信登录接入方案.md` 的 Step 3):**后续所有真机验证都在此客户端中进行**。 |
|
||||
- iOS 需要在 `app.config.js` 补运营商要求的隐私描述文案(`expo-build-properties` / `infoPlist`),Android 需确认运营商要求的权限未被裁剪。 |
|
||||
|
|
||||
### Step 4 · 替换端上取号(唯一改动点 · 前端) |
|
||||
文件:**`src/lib/carrier-sdk.ts`**(不是 `api.ts` —— 取号能力已隔离到适配层,与微信登录的 |
|
||||
`src/lib/wechat-sdk.ts` 对称)。把下面 5 个 mock 函数体换成 SDK 调用,**对外签名不变**: |
|
||||
|
|
||||
| 适配层函数 | 对应原生 API(Android / iOS) | |
|
||||
|---|---| |
|
||||
| `isCarrierAuthAvailable()` | SDK 初始化结果 | |
|
||||
| `getCarrierNetworkInfo()` | `getNetworkType` / `networkInfo` | |
|
||||
| `prefetchNumber(timeoutMs)` | `getPhoneInfo` / `getPhoneNumberCompletion`(只建缓存,不返号码) | |
|
||||
| `getCarrierNumber(timeoutMs)` | 同上取号 → `{ phone, token, operator }` | |
|
||||
| `clearNumberCache()` | `delScrip` / `delectScrip` | |
|
||||
|
|
||||
`api.ts` 的 `fetchCarrierPhone()` 只负责三件业务规则:套超时(`CARRIER_SDK_TIMEOUT_MS` = 8000ms)、 |
|
||||
用 `carrierOf()` 归一化运营商、调 `maskPhone()` 脱敏,**它不碰 SDK,接真实 SDK 时不用改它**。 |
|
||||
|
|
||||
```ts |
|
||||
// src/lib/carrier-sdk.ts · getCarrierNumber 真实实现示例 |
|
||||
const r = await NumberAuthSDK.getMobileNumber(); // 5 分钟内有效,用后即废 |
|
||||
if (!r?.token) throw new CarrierAuthError('unknown'); |
|
||||
return { phone: r.phone, token: r.token, operator: r.operator }; |
|
||||
``` |
|
||||
|
|
||||
- **失败一律抛 `CarrierAuthError(reason)` 或返回空串,绝不返回假号码** —— 页面靠 |
|
||||
`carrierToken === ''` 判定失败并置灰按钮,返回假号码会让失败分支永远进不去。 |
|
||||
- 运营商标识各家形状不同(中文名 / 大写缩写 / 数字码),`carrierOf()` 已做归一化,认不出即 `unknown`。 |
|
||||
|
|
||||
### Step 4.5 · 四个不能漏的工程细节(来自运营商文档,非我们的设计选择) |
|
||||
|
|
||||
| 细节 | 说明 | 落点 | |
|
||||
|---|---|---| |
|
||||
| **超时** | SDK 默认 8s;不包装则弱信号下取号无限挂起,页面停在「正在获取本机号码…」没有出口 | `CARRIER_SDK_TIMEOUT_MS` + `guard()` 超时包装 | |
|
||||
| **蜂窝网络预检** | 取号走数据网关,纯 Wi-Fi 取不到(SDK 会瞬切蜂窝再切回,但用户关流量时必然失败) | `getCarrierNetworkInfo()`,失败原因 `no-cellular` | |
|
||||
| **取号缓存 scrip** | 缓存提高成功率、降时延,且允许未开蜂窝时取号;换卡时 SDK 自动失效 | `prefetchNumber()` 建缓存 / `clearNumberCache()` 主动清 | |
|
||||
| **敏感动作清缓存** | 注销、换绑手机号、退出登录后应主动 `delScrip`,避免下次取号命中旧缓存 | `clearNumberCache()`(**尚未接到注销/换绑流程,待补**) | |
|
||||
|
|
||||
> 缓存清理目前只有函数、没有调用方 —— 等账号注销 / 换绑手机号功能落地时再接,不提前铺。 |
|
||||
|
|
||||
### Step 5 · 实现后端换号接口 |
|
||||
| 接口 | 方法 | 职责 | 关键返回 | |
|
||||
|---|---|---|---| |
|
||||
| `/api/auth/carrier/number` | POST | 端上取号的**服务端旁路**(可选:部分方案把 token 的校验放在这里) | `CarrierPhoneResult` | |
|
||||
| `/api/auth/login-by-carrier` | POST | 用一次性 `carrierToken` 向运营商换取真实号码 → 查库 → 建号/登录 | `LoginResult`:`token` + **已核验的 `phone`** + `expiresAt` | |
|
||||
|
|
||||
- 后端须保证:`carrierToken` 一次性、来源可校验、过期拒绝;**号码以后端换取结果为准**(前端传来的号码一律不信任)。 |
|
||||
- 该接口同时也是「登录即注册」入口:号码在库中不存在时应自动建档(与验证码通道一致)。 |
|
||||
|
|
||||
### Step 6 · 清除 mock 残留 |
|
||||
- 删除 `src/lib/carrier-sdk.ts` 的 mock 块:`MOCK_CARRIER_PHONE`、`mockState`、`setMockCarrierFailure`、`setMockCarrierHang`(`guard()` 的超时包装要留,真实取号同样需要)。 |
|
||||
- `api.ts` 中 `loginByCarrier()` 的 mock 返回与 `MOCK_CARRIER_PHONE` 引用一并替换为真接口。 |
|
||||
- `src/__tests__/carrier-sdk.test.ts` 中断言 mock 行为的用例改为断言**适配层契约**(失败不返假号、超时必返 timeout、归一化认不出即 unknown),这部分在真实取号后依然是有效契约。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 5. 手动验收路径(可复现) |
|
||||
|
|
||||
1. 在自定义客户端(非 Expo Go)上安装,**插入有效 SIM 卡并开启数据流量**(取号需要蜂窝网络鉴权,纯 Wi-Fi 下多数方案会失败)。 |
|
||||
2. 进入 P2Q → 期望**不输入任何号码**即展示掩码本机号码(与 SIM 卡一致),页脚显示「认证服务由 xx 提供」。 |
|
||||
3. 点「一键登录」→ 期望直接进 P3,且后续注册流程(P2P/P2R 预填手机号、B3 联系人电话)**都用这个真号码**。 |
|
||||
4. 关流量 / 开飞行模式 / 拔卡 → 期望号码位显示「未能获取本机号码」、按钮置灰、两个出口(重新获取 / 改用验证码登录)可用。 |
|
||||
5. 令牌过期后(>5 分钟再点)→ 期望提示重新取号,不出现「点了没反应」。 |
|
||||
6. 回归 `npx tsc --noEmit` 与 `npx eslint .` 均 0 错误。 |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 6. 风险与待确认项 |
|
||||
|
|
||||
| 项 | 风险/阻塞 | 处置 | |
|
||||
|---|---|---| |
|
||||
| 运营商服务开通 | 需企业资质与签名指纹,非代码可解 | Step 1 前置,未开通前无法真机验证 | |
|
||||
| 蜂窝网络依赖 | 无 SIM / 无流量 / 双卡选错卡都会取号失败 | 失败分支已就位;验收时明确用流量 | |
|
||||
| 端上号码不可信 | 改机工具可伪造取号结果 | 号码一律以后端换取结果为准(Step 5) | |
|
||||
| Expo Go 无法验证 | 原生模块不存在,import 期即崩 | 开发期保留 mock 分支 + dev build 出包 | |
|
||||
| SDK 选型 | 各家插件维护状态不一 | 接入时以官方 docs 为准(AGENTS.md 要求先读 `docs.expo.dev/versions/v57.0.0/`) | |
|
||||
|
|
||||
--- |
|
||||
|
|
||||
## 7. 一句话给执行者 |
|
||||
|
|
||||
**别动页面、hook、类型、`api.ts`。** 只动两处:`src/lib/carrier-sdk.ts` 的 5 个函数体(换端上 SDK 调用,失败抛 `CarrierAuthError` 或返回空串)和 `loginByCarrier()`(换真接口,号码以后端为准),配齐运营商号码认证服务 + EAS 自定义客户端,一键登录即可真机走通。 |
|
||||
File diff suppressed because it is too large
Loading…
Reference in new issue