Browse Source

refactor(auth): 登录落地与换端改以服务端角色为准

- 新增 useLoginLanding:验证码/密码/一键登录三入口共用的登录后落地判定,按后端 identitySelectionRequired 与服务器角色决定去身份选择还是对应端
- 新增 useIdentitySwitch:切换企业端改为调用 POST /auth/identity/select,不再本地改角色导致鉴权错位
- AuthContext/RoleContext/token-store 及微信、密码、注册各登录 Hook 与引导页适配新契约
- 移除废弃安卓自适应图标资源与根目录过期联调文档,归档 scripts/api.ts.bak
master
doublex001 4 weeks ago
parent
commit
5d2b8996e1
  1. 10
      AGENTS.md
  2. 19
      app/(enterprise)/_layout.tsx
  3. 28
      app/(enterprise)/enterprise-overview.tsx
  4. 20
      app/(tabs)/_layout.tsx
  5. 34
      app/(tabs)/profile.tsx
  6. 120
      app/index.tsx
  7. 25
      app/settings.tsx
  8. BIN
      assets/android-icon-background.png
  9. BIN
      assets/android-icon-foreground.png
  10. BIN
      assets/android-icon-monochrome.png
  11. 42
      components/onboarding/screens/B3Recruiter.tsx
  12. 50
      components/onboarding/screens/P2PasswordLogin.tsx
  13. 30
      components/onboarding/screens/P2PasswordReset.tsx
  14. 35
      components/onboarding/screens/P2PhoneCode.tsx
  15. 17
      components/onboarding/screens/P2QuickLogin.tsx
  16. 30
      components/onboarding/screens/P3Identity.tsx
  17. 196
      docs/applications-500-diagnosis.md
  18. 109
      docs/联调路径校正映射.md
  19. 11
      src/__tests__/http.test.ts
  20. 80
      src/__tests__/password.test.ts
  21. 81
      src/api/auth.ts
  22. 37
      src/api/http.ts
  23. 232
      src/context/AuthContext.tsx
  24. 83
      src/context/RoleContext.tsx
  25. 4
      src/hooks/index.ts
  26. 70
      src/hooks/useIdentitySwitch.ts
  27. 56
      src/hooks/useLoginLanding.ts
  28. 7
      src/hooks/usePasswordLogin.ts
  29. 6
      src/hooks/usePasswordReset.ts
  30. 50
      src/hooks/useRegisterFinish.ts
  31. 18
      src/hooks/useWechatBindPhone.ts
  32. 17
      src/hooks/useWechatLogin.ts
  33. 11
      src/lib/token-store.ts
  34. 17
      src/types/auth.ts
  35. 11
      src/types/wechat-auth.ts
  36. 87
      后端联调前端实现.md
  37. 159
      微信登录接入方案.md
  38. 138
      接口联调问题清单.md
  39. BIN
      职来后端接口联调说明.docx
  40. 167
      运营商一键登录接入方案.md
  41. 1921
      项目开发状态.md

10
AGENTS.md

@ -9,7 +9,7 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before
======================================== ========================================
【核心原则】 【核心原则】
亲爱的,你是react native+expo开发工程师,或许某天我将会离开你,你要加油,永远爱你 亲爱的,你是react native+expo开发工程师。
后端文档:https://s.apifox.cn/a64fdc2b-3980-4dc1-9b02-b1097417e76e 后端文档:https://s.apifox.cn/a64fdc2b-3980-4dc1-9b02-b1097417e76e
测试号:手机号:19900009021 测试号:手机号:19900009021
密码:ZhilaiTest!2026#A 密码:ZhilaiTest!2026#A
@ -19,14 +19,6 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before
引导页背景应当是蓝和绿的渐变色 引导页背景应当是蓝和绿的渐变色
仓库:https://gitea.baskbuddy.com/baskbuddy/mydreamjob 仓库:https://gitea.baskbuddy.com/baskbuddy/mydreamjob
严格要求:请在每周的工作日的9:20的时候拉取一次远端仓库、每天17:50提交一次git仓库并推送到远端,每天一次,其他时间可以改动但不需要提交,说明今天新增与改动的内容,上班时间是9:00-18:00,有页面出口就一定要有入口。 严格要求:请在每周的工作日的9:20的时候拉取一次远端仓库、每天17:50提交一次git仓库并推送到远端,每天一次,其他时间可以改动但不需要提交,说明今天新增与改动的内容,上班时间是9:00-18:00,有页面出口就一定要有入口。
> **提交之后必须推送(2026-09-18 补充)**。原约定只写了「提交」,没写「推送」,实际后果是
> **2026-09-11 至 09-17 整整一周的 12 个提交从未离开本机**,远端一直停在 09-10:
> ① 这段工作只存在于一台机器上,磁盘故障即丢失;
> ② 后端同事(Java + SpringBoot)看不到前端的接口契约;
> ③ 9:20 的「拉取」形同虚设 —— 本地领先 12、落后 0,拉不到任何东西。
> 故 17:50 的动作是**两步**:`git commit` → `git push`。次日 9:20 拉取前先看
> `git rev-list --left-right --count HEAD...@{u}`,正常应为 `0 0`。
业务逻辑分层管理,不是没有,而是被妥善组织: 业务逻辑分层管理,不是没有,而是被妥善组织:
+------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+ +------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+
| 层级 | 职责 | 业务逻辑示例 | | 层级 | 职责 | 业务逻辑示例 |

19
app/(enterprise)/_layout.tsx

@ -1,11 +1,9 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router/js-tabs'; import { Tabs } from 'expo-router/js-tabs';
import type { BottomTabBarProps } from 'expo-router/js-tabs'; import type { BottomTabBarProps } from 'expo-router/js-tabs';
import { useFocusEffect } from 'expo-router'; import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native'; import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import { useRole } from '../../src/context/RoleContext';
/** 企业端默认落地「岗位」Tab(与求职端首屏一致) */ /** 企业端默认落地「岗位」Tab(与求职端首屏一致) */
export const unstable_settings = { export const unstable_settings = {
@ -141,16 +139,11 @@ function TabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) {
* 绿色 success 强调,与求职端蓝色 primary 形成视觉区分。 * 绿色 success 强调,与求职端蓝色 primary 形成视觉区分。
*/ */
export default function EnterpriseTabLayout() { export default function EnterpriseTabLayout() {
const { setRole } = useRole(); /*
* 这里**不再**写角色:原实现在 useFocusEffect 里无条件 `setRole('enterprise')`,
// 用 useFocusEffect:进入/返回该端时随焦点写入角色, * 等于「点进企业端 Tab 就变成企业账号」,与服务端事实无关。
// 避免「切换到企业端」后返回时 layout 未重新挂载导致 role 残留 candidate。 * 角色现在只由 AuthContext 按服务器下发维护。
useFocusEffect( */
useCallback(() => {
setRole('enterprise');
}, [setRole]),
);
return ( return (
<Tabs <Tabs
screenOptions={{ headerShown: false }} screenOptions={{ headerShown: false }}

28
app/(enterprise)/enterprise-overview.tsx

@ -2,6 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -9,9 +10,8 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useEnterpriseOverview } from '../../src/hooks'; import { useEnterpriseOverview, useIdentitySwitch } from '../../src/hooks';
import { LoadingState, ErrorState } from '../../src/components/StateView'; import { LoadingState, ErrorState } from '../../src/components/StateView';
import { useRole } from '../../src/context/RoleContext';
import {colors, headerGradient, semanticColorTokens, spacing} from '../../src/theme'; import {colors, headerGradient, semanticColorTokens, spacing} from '../../src/theme';
import type { EnterpriseOverview, EnterpriseQuickAction, EnterpriseStat, JobEvaluationSummary, MenuItem } from '../../src/types'; import type { EnterpriseOverview, EnterpriseQuickAction, EnterpriseStat, JobEvaluationSummary, MenuItem } from '../../src/types';
@ -222,14 +222,23 @@ function MenuList({ items }: { items: MenuItem[] }) {
*/ */
function CandidateEntryCard() { function CandidateEntryCard() {
const router = useRouter(); const router = useRouter();
const { setRole } = useRole(); const { switchTo, switching } = useIdentitySwitch();
/** 回切求职端同样是服务端操作,本地改角色不算数 */
const handlePress = async () => {
const outcome = await switchTo('STUDENT');
if (!outcome.ok) {
Alert.alert('无法进入求职端', outcome.message ?? '请稍后重试');
return;
}
router.replace('/(tabs)/jobs');
};
return ( return (
<Pressable <Pressable
style={styles.candidateCard} style={[styles.candidateCard, switching && styles.candidateCardBusy]}
onPress={() => { onPress={() => void handlePress()}
setRole('candidate'); disabled={switching}
router.replace('/(tabs)/jobs');
}}
hitSlop={4} hitSlop={4}
> >
<View style={styles.candidateIconWrap}> <View style={styles.candidateIconWrap}>
@ -528,6 +537,9 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
color: colors.textPrimary, color: colors.textPrimary,
}, },
candidateCardBusy: {
opacity: 0.6,
},
candidateCard: { candidateCard: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

20
app/(tabs)/_layout.tsx

@ -1,11 +1,9 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router/js-tabs'; import { Tabs } from 'expo-router/js-tabs';
import type { BottomTabBarProps } from 'expo-router/js-tabs'; import type { BottomTabBarProps } from 'expo-router/js-tabs';
import { useFocusEffect } from 'expo-router'; import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native'; import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import { useRole } from '../../src/context/RoleContext';
/** 无 index 路由,默认进入「岗位」Tab 作为落地页 */ /** 无 index 路由,默认进入「岗位」Tab 作为落地页 */
export const unstable_settings = { export const unstable_settings = {
@ -163,16 +161,12 @@ function TabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) {
* 底部 Tab 导航:4 个 Tab(岗位/沟通/成长/我的),高级感极简风格 * 底部 Tab 导航:4 个 Tab(岗位/沟通/成长/我的),高级感极简风格
*/ */
export default function TabLayout() { export default function TabLayout() {
const { setRole } = useRole(); /*
* 这里**不再**写角色。
// 用 useFocusEffect:进入/返回该端时随焦点写入角色, * 原实现在 useFocusEffect 里无条件 `setRole('candidate')` —— 只要用户点进求职端 Tab,
// 避免「从企业端返回求职端」时 layout 未重新挂载导致 role 残留 enterprise。 * 本地账号角色就变成求职者,哪怕服务端记录的是企业;下次冷启动会被直接放进求职端。
useFocusEffect( * 角色现在只由 AuthContext 按服务器下发(登录 / 选身份 / /auth/me)维护。
useCallback(() => { */
setRole('candidate');
}, [setRole]),
);
return ( return (
<Tabs <Tabs
screenOptions={{ headerShown: false }} screenOptions={{ headerShown: false }}

34
app/(tabs)/profile.tsx

@ -3,6 +3,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -10,7 +11,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useProfileData } from '../../src/hooks'; import { useIdentitySwitch, useProfileData } from '../../src/hooks';
import { UNSET_LABEL } from '../../src/constants'; import { UNSET_LABEL } from '../../src/constants';
import { Avatar } from '../../components/onboarding/ui/Avatar'; import { Avatar } from '../../components/onboarding/ui/Avatar';
import { isAvatarPreset } from '../../components/onboarding/assets'; import { isAvatarPreset } from '../../components/onboarding/assets';
@ -149,17 +150,31 @@ function StatsRow({ stats }: { stats: UserStats }) {
); );
} }
/** 切换到企业端卡片 */ /**
* 切换到企业端卡片。
*
* 「切端」是服务端操作:调 `POST /auth/identity/select` 由后端确认这个账号确实可以是企业。
* 早先这里只写本地 `setRole('enterprise')` 就跳进企业端 —— 账号在服务器眼里仍是求职者,
* 后续所有企业接口都会按求职者鉴权,页面却按企业渲染(能看到入口、点进去全失败)。
*/
function EnterpriseEntryCard({ entry }: { entry: EnterpriseEntry }) { function EnterpriseEntryCard({ entry }: { entry: EnterpriseEntry }) {
const router = useRouter(); const router = useRouter();
const { setRole } = useRole(); const { switchTo, switching } = useIdentitySwitch();
const handlePress = async () => {
const outcome = await switchTo('COMPANY');
if (!outcome.ok) {
Alert.alert('无法进入企业端', outcome.message ?? '请稍后重试');
return;
}
router.replace('/(enterprise)/enterprise-overview');
};
return ( return (
<Pressable <Pressable
style={styles.enterpriseCard} style={[styles.enterpriseCard, switching && styles.enterpriseCardBusy]}
onPress={() => { onPress={() => void handlePress()}
setRole('enterprise'); disabled={switching}
router.replace('/(enterprise)/enterprise-overview');
}}
hitSlop={4} hitSlop={4}
> >
<View style={styles.enterpriseIconWrap}> <View style={styles.enterpriseIconWrap}>
@ -712,6 +727,9 @@ const styles = StyleSheet.create({
statValue: { fontSize: 18, fontWeight: '800', color: colors.textPrimary }, statValue: { fontSize: 18, fontWeight: '800', color: colors.textPrimary },
statLabel: { fontSize: 12, color: colors.textSecondary, marginTop: 4 }, statLabel: { fontSize: 12, color: colors.textSecondary, marginTop: 4 },
/* ---- 切换到企业端 ---- */ /* ---- 切换到企业端 ---- */
enterpriseCardBusy: {
opacity: 0.6,
},
enterpriseCard: { enterpriseCard: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

120
app/index.tsx

@ -1,19 +1,58 @@
import { Redirect } from 'expo-router'; import { Redirect, useRouter } from 'expo-router';
import { StyleSheet, View } from 'react-native'; import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
import { useAuth } from '../src/context/AuthContext'; import { useAuth } from '../src/context/AuthContext';
import { useRole } from '../src/context/RoleContext'; import { colors, spacing } from '../src/theme';
import { colors } from '../src/theme';
/** /**
* 应用总入口(根路由 /)。 * 应用总入口(根路由 /)。
* 按「引导 → 登录 → 选择身份 → 对应端」漏斗放行,保证每个页面都有合理的前置入口。 * 按「引导 → 登录 → 与服务端核对身份 → 对应端」漏斗放行。
*
* **角色不以本地缓存为准**:本地存的 role 只代表上次是什么。冷启动有 Token 时先调
* `/auth/me` 核对,核对成功才跳业务页;核对失败**不**退回本地角色硬跳 ——
* 那会把已被后台改成企业(或已禁用)的账号继续放进求职端,页面按错误身份渲染并一路报错。
* 失败时停在本页给出重试入口,把决定权交回用户。
*/ */
export default function Index() { export default function Index() {
const { onboardingSeen, isTokenValid, isHydrated: authHydrated } = useAuth(); const {
const { role, isHydrated: roleHydrated } = useRole(); onboardingSeen,
isTokenValid,
isHydrated: authHydrated,
role,
isRoleVerified,
verifySession,
} = useAuth();
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState<Error | null>(null);
/** 重试计数:仅用于重新触发核对 effect */
const [attempt, setAttempt] = useState(0);
const router = useRouter();
/** 需要核对的条件:本地存储已就绪 + 看过引导 + 有有效 Token + 尚未核对过 */
const needVerify = authHydrated && onboardingSeen && isTokenValid && !isRoleVerified;
useEffect(() => {
if (!needVerify) return;
let active = true;
setVerifying(true);
setVerifyError(null);
verifySession()
.catch((e: unknown) => {
if (active) setVerifyError(e instanceof Error ? e : new Error('身份核对失败'));
})
.finally(() => {
if (active) setVerifying(false);
});
return () => {
active = false;
};
}, [needVerify, verifySession, attempt]);
const retry = useCallback(() => setAttempt((n) => n + 1), []);
// 本地存储读取完成前不做任何跳转决策,否则已登录用户会闪现引导页/注册页 // 本地存储读取完成前不做任何跳转决策,否则已登录用户会闪现引导页/注册页
if (!authHydrated || !roleHydrated) { if (!authHydrated) {
return <View style={styles.container} />; return <View style={styles.container} />;
} }
@ -23,9 +62,36 @@ export default function Index() {
if (!isTokenValid) { if (!isTokenValid) {
return <Redirect href="/register" />; return <Redirect href="/register" />;
} }
// 有 Token 但角色未与服务器核对:核对中或失败都不能放行
if (!isRoleVerified) {
if (verifying) {
return (
<View style={[styles.container, styles.center]}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.hint}>正在确认登录状态…</Text>
</View>
);
}
return (
<View style={[styles.container, styles.center]}>
<Text style={styles.errorTitle}>无法确认登录状态</Text>
<Text style={styles.hint}>{verifyError?.message ?? '请检查网络后重试'}</Text>
<Pressable style={styles.retryBtn} onPress={retry}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
<Pressable style={styles.linkBtn} onPress={() => router.replace('/register')}>
<Text style={styles.linkText}>重新登录</Text>
</Pressable>
</View>
);
}
// 核对通过但服务器说角色未定(UNASSIGNED)→ 去选身份
if (!role) { if (!role) {
return <Redirect href="/register" />; return <Redirect href="/register" />;
} }
// 两个路由组均无 index,必须用显式落地路由,否则落地页空白 // 两个路由组均无 index,必须用显式落地路由,否则落地页空白
return ( return (
<Redirect <Redirect
@ -39,4 +105,42 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: colors.white, backgroundColor: colors.white,
}, },
center: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.framework,
},
hint: {
marginTop: spacing.card,
fontSize: 13,
color: colors.textSecondary,
textAlign: 'center',
},
errorTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
retryBtn: {
marginTop: spacing.framework,
paddingHorizontal: 28,
height: 40,
borderRadius: spacing.card,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 14,
fontWeight: '600',
color: colors.white,
},
linkBtn: {
marginTop: spacing.card,
},
linkText: {
fontSize: 13,
fontWeight: '600',
color: colors.primaryText,
},
}); });

25
app/settings.tsx

@ -3,7 +3,7 @@ import { useRouter } from 'expo-router';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { colors , spacing } from '../src/theme'; import { colors , spacing } from '../src/theme';
import { useAppCache } from '../src/hooks'; import { useAppCache, useIdentitySwitch } from '../src/hooks';
import { formatBytes } from '../src/utils'; import { formatBytes } from '../src/utils';
import { useAuth } from '../src/context/AuthContext'; import { useAuth } from '../src/context/AuthContext';
import { useRole } from '../src/context/RoleContext'; import { useRole } from '../src/context/RoleContext';
@ -102,7 +102,8 @@ function Header({ onBack }: { onBack: () => void }) {
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { logout } = useAuth(); const { logout } = useAuth();
const { role, setRole } = useRole(); const { role } = useRole();
const { switchTo } = useIdentitySwitch();
const { const {
label: cacheLabel, label: cacheLabel,
loading: cacheLoading, loading: cacheLoading,
@ -153,14 +154,20 @@ export default function SettingsScreen() {
]); ]);
}; };
const handleIdentitySwitch = () => { /**
if (role === 'enterprise') { * 身份切换:由服务端裁决(`POST /auth/identity/select`),成功即采用返回的角色与凭证。
setRole('candidate'); * 本地不再直接改角色 —— 那只是改了界面上的一个字段,账号在服务器上没变。
router.replace('/(tabs)/jobs'); */
} else { const handleIdentitySwitch = async () => {
setRole('enterprise'); const target = role === 'enterprise' ? 'STUDENT' : 'COMPANY';
router.replace('/(enterprise)/enterprise-overview'); const outcome = await switchTo(target);
if (!outcome.ok) {
Alert.alert('无法切换身份', outcome.message ?? '请稍后重试');
return;
} }
router.replace(
outcome.role === 'enterprise' ? '/(enterprise)/enterprise-overview' : '/(tabs)/jobs',
);
}; };
const handlePress = (item: SettingsItem) => { const handlePress = (item: SettingsItem) => {

BIN
assets/android-icon-background.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

BIN
assets/android-icon-foreground.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

BIN
assets/android-icon-monochrome.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

42
components/onboarding/screens/B3Recruiter.tsx

@ -3,6 +3,7 @@ import { Image, Pressable, StyleSheet, Text, TextInput, View } from 'react-nativ
import { colors, spacing } from '../../../src/theme'; import { colors, spacing } from '../../../src/theme';
import { import {
useEnterpriseCertification, useEnterpriseCertification,
useIdentitySwitch,
useImagePick, useImagePick,
useOnboarding, useOnboarding,
useRegisterFinish, useRegisterFinish,
@ -16,7 +17,10 @@ export function B3Recruiter() {
const finish = useRegisterFinish(); const finish = useRegisterFinish();
const { pick, picking, error: pickError } = useImagePick(); const { pick, picking, error: pickError } = useImagePick();
const { submit, submitting } = useEnterpriseCertification(); const { submit, submitting } = useEnterpriseCertification();
const { switchTo, switching } = useIdentitySwitch();
const [submitFailed, setSubmitFailed] = useState(false); const [submitFailed, setSubmitFailed] = useState(false);
/** 服务端返回的失败原因(身份选择或认证提交),比「提交失败」四个字有用 */
const [failReason, setFailReason] = useState<string | null>(null);
// 三项都是认证必需,缺一项不放行:没有营业执照的「企业认证」提交不出有意义的结果 // 三项都是认证必需,缺一项不放行:没有营业执照的「企业认证」提交不出有意义的结果
const canSubmit = const canSubmit =
@ -31,10 +35,30 @@ export function B3Recruiter() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!canSubmit || submitting) return; if (!canSubmit || submitting || switching) return;
setSubmitFailed(false); setSubmitFailed(false);
setFailReason(null);
try { try {
// 提交审核状态由接口返回驱动,不再用本地 useState 假装「已提交」 /*
* 第一步:先让服务端确认这个账号是企业身份。
* 契约 POST /auth/identity/select 要求选 COMPANY 时必须带 company 资料;
* 这一步成功后才拿得到企业角色,后面的认证提交才有意义 —— 顺序不能反。
* 「提交认证」也**不等于**「认证通过」:认证结果由审核决定,本地不做任何置位。
*/
const identityOutcome = await switchTo('COMPANY', {
name: data.recruiterCompany.trim(),
industry: '',
description: '',
website: '',
address: '',
});
if (!identityOutcome.ok) {
setSubmitFailed(true);
setFailReason(identityOutcome.message ?? '企业身份创建失败,请稍后重试');
return;
}
// 第二步:提交企业认证资料,审核状态由接口返回驱动
const res = await submit({ const res = await submit({
companyName: data.recruiterCompany.trim(), companyName: data.recruiterCompany.trim(),
// B3 不采集统一社会信用代码,由企业端「企业认证」页补全(该页已存在且有入口) // B3 不采集统一社会信用代码,由企业端「企业认证」页补全(该页已存在且有入口)
@ -46,13 +70,15 @@ export function B3Recruiter() {
}); });
if (!res.success) { if (!res.success) {
setSubmitFailed(true); setSubmitFailed(true);
setFailReason('认证提交未通过,请核对资料后重试');
return; return;
} }
// 落地前 useRegisterFinish 会 flushSave,把认证资料一起落库 // 落地前 useRegisterFinish 会 flushSave,把认证资料一起落库
await finish('enterprise'); await finish('enterprise');
} catch { } catch (e) {
// 真实后端就绪后这里会接到网络异常
setSubmitFailed(true); setSubmitFailed(true);
setFailReason((e as Error).message || '网络异常,请稍后重试');
} }
}; };
@ -96,13 +122,15 @@ export function B3Recruiter() {
placeholderTextColor={colors.textHint} placeholderTextColor={colors.textHint}
/> />
{submitFailed ? <Text style={styles.error}>认证提交失败,请稍后重试</Text> : null} {submitFailed ? (
<Text style={styles.error}>{failReason ?? '认证提交失败,请稍后重试'}</Text>
) : null}
<View style={styles.footer}> <View style={styles.footer}>
<PrimaryButton <PrimaryButton
label="提交认证" label="提交认证"
active={canSubmit && !submitting} active={canSubmit && !submitting && !switching}
loading={submitting} loading={submitting || switching}
onPress={() => void handleSubmit()} onPress={() => void handleSubmit()}
/> />
</View> </View>

50
components/onboarding/screens/P2PasswordLogin.tsx

@ -1,13 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors, spacing } from '../../../src/theme'; import { colors, spacing } from '../../../src/theme';
import { useAgreementGate, useOnboarding, usePasswordLogin } from '../../../src/hooks'; import { useAgreementGate, useLoginLanding, useOnboarding, usePasswordLogin } from '../../../src/hooks';
import { isValidPhone } from '../../../src/utils'; import { isValidPhone } from '../../../src/utils';
import { useAuth } from '../../../src/context/AuthContext'; import { useAuth } from '../../../src/context/AuthContext';
import { useRole } from '../../../src/context/RoleContext';
import { fetchCurrentUser } from '../../../src/api';
import { PasswordField } from '../../../src/components/PasswordField'; import { PasswordField } from '../../../src/components/PasswordField';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
@ -24,10 +21,9 @@ export function P2PasswordLogin() {
const { data, setData, navigate, back } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const { gate, modalProps } = useAgreementGate(); const { gate, modalProps } = useAgreementGate();
const { login, loading, error } = usePasswordLogin(); const { login, loading, error } = usePasswordLogin();
/** 双 Token 写入:换取令牌后必须落库,否则冷启动会被打回登录页 */ /** 会话写入 + 落地判定(双 Token + 服务器角色) */
const { setSession } = useAuth(); const { land, go } = useLoginLanding();
const { role, setRole } = useRole(); const { verifySession } = useAuth();
const router = useRouter();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@ -38,28 +34,34 @@ export function P2PasswordLogin() {
const handleLogin = async () => { const handleLogin = async () => {
if (!canSubmit || loading) return; if (!canSubmit || loading) return;
let result;
try { try {
const result = await login(phone, password); result = await login(phone, password);
// 双 Token 落库(access + refresh),后续请求自动带 Bearer
await setSession(result.accessToken, result.refreshToken, result.expiresAt);
} catch { } catch {
// 失败原因由 usePasswordLogin 记录,页面下方展示 // 失败原因由 usePasswordLogin 记录,页面下方展示
return; return;
} }
// 登录响应里就带角色:直接按它落地(消费一次,不额外打接口)
// 角色以 /auth/me 后端返回为准(规范 §5),不一致时纠正路由 const target = land(result);
let landingRole = role; if (target !== 'identity') {
try { go(target);
const me = await fetchCurrentUser(); return;
setRole(me.role);
landingRole = me.role;
} catch {
// /auth/me 失败时退回到本地已选身份,不阻断登录
} }
// 已有身份的老用户直接进对应主界面,新用户才去选身份 /*
if (landingRole) { * 登录响应要求或暗示还需选身份时,再用 /auth/me 复核一次:
router.replace(landingRole === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); * 早先这里在 /auth/me 失败后「退回本地已选身份」并继续跳转 —— 本地角色只是上次的
* 缓存,服务器此刻可能已经把它改了(例如选了企业、又被后台调整)。拿过期缓存把人
* 放进业务页面,比停下来更糟。现在失败即提示重试,不进业务页。
*/
try {
const verified = await verifySession();
if (verified) {
go(verified === 'enterprise' ? 'enterprise' : 'candidate');
return;
}
} catch (e) {
Alert.alert('无法确认账号身份', (e as Error).message || '请检查网络后重试');
return; return;
} }
navigate('P3'); navigate('P3');

30
components/onboarding/screens/P2PasswordReset.tsx

@ -1,12 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors, spacing } from '../../../src/theme'; import { colors, spacing } from '../../../src/theme';
import { useAgreementGate, useOnboarding, usePasswordReset, useSmsCode } from '../../../src/hooks'; import { useAgreementGate, useOnboarding, usePasswordReset, useSmsCode } from '../../../src/hooks';
import { isValidPhone, isValidSmsCode, SMS_CODE_MAX_LENGTH } from '../../../src/utils'; import { isValidPhone, isValidSmsCode, SMS_CODE_MAX_LENGTH } from '../../../src/utils';
import { useAuth } from '../../../src/context/AuthContext'; import { useAuth } from '../../../src/context/AuthContext';
import { useRole } from '../../../src/context/RoleContext';
import { PasswordField } from '../../../src/components/PasswordField'; import { PasswordField } from '../../../src/components/PasswordField';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { CountdownButton } from '../ui/CountdownButton'; import { CountdownButton } from '../ui/CountdownButton';
@ -29,10 +27,12 @@ export function P2PasswordReset() {
const { gate, modalProps } = useAgreementGate(); const { gate, modalProps } = useAgreementGate();
const { reset, loading, error, checkPassword } = usePasswordReset(); const { reset, loading, error, checkPassword } = usePasswordReset();
const { send: sendCode, error: smsError } = useSmsCode(); const { send: sendCode, error: smsError } = useSmsCode();
/** 重置成功即写入登录态,用户不必再用新密码登录一次 */ /**
const { login: writeAuth } = useAuth(); * 重置密码**不会**返回登录态:契约 /auth/password/reset 的响应体只有
const { role } = useRole(); * {passwordConfigured, reauthenticationRequired}。所以这里不能写会话,
const router = useRouter(); * 只能清掉可能残留的旧会话(改密后旧凭证可能已被后端撤销)并要求重新登录。
*/
const { logout } = useAuth();
const [stage, setStage] = useState<Stage>('verify'); const [stage, setStage] = useState<Stage>('verify');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@ -66,18 +66,20 @@ export function P2PasswordReset() {
let result; let result;
try { try {
result = await reset({ phone, code, password }); result = await reset({ phone, code, password });
writeAuth(result.token);
} catch { } catch {
// 失败原因由 usePasswordReset 记录,页面下方展示 // 失败原因由 usePasswordReset 记录,页面下方展示
return; return;
} }
// 已有身份的老用户直接进对应主界面,新用户才去选身份 // 旧会话作废:后端未下发新凭证,带着旧 Token 继续用等于用一个可能已失效的身份
if (role) { logout();
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); Alert.alert(
return; '密码已设置',
} result.reauthenticationRequired
navigate('P3'); ? '出于安全考虑,请使用新密码重新登录。'
: '请使用新密码登录。',
[{ text: '去登录', onPress: () => navigate('P2P') }],
);
}; };
return ( return (

35
components/onboarding/screens/P2PhoneCode.tsx

@ -4,9 +4,15 @@ import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-nativ
import { colors, spacing } from '../../../src/theme'; import { colors, spacing } from '../../../src/theme';
import { maskPhone } from '../../../src/api'; import { maskPhone } from '../../../src/api';
import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants'; import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants';
import { useAgreementGate, useLogin, useOnboarding, useSmsCode, useWechatBindPhone } from '../../../src/hooks'; import {
useAgreementGate,
useLogin,
useLoginLanding,
useOnboarding,
useSmsCode,
useWechatBindPhone,
} from '../../../src/hooks';
import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils'; import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils';
import { useAuth } from '../../../src/context/AuthContext';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { CountdownButton } from '../ui/CountdownButton'; import { CountdownButton } from '../ui/CountdownButton';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
@ -22,8 +28,8 @@ export function P2PhoneCode() {
const { gate, modalProps } = useAgreementGate(); const { gate, modalProps } = useAgreementGate();
const { login, loading: logging, error: loginError } = useLogin(); const { login, loading: logging, error: loginError } = useLogin();
const { send: sendCode, error: smsError } = useSmsCode(); const { send: sendCode, error: smsError } = useSmsCode();
/** 登录态写入:此前本页只跳 P3 不写 token,导致登录态始终为空 */ /** 登录成功后的会话写入与落地判定(双 Token + 服务器角色) */
const { login: writeAuth } = useAuth(); const { land, go } = useLoginLanding();
const { bind, loading: binding, error: bindError } = useWechatBindPhone(); const { bind, loading: binding, error: bindError } = useWechatBindPhone();
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@ -69,15 +75,20 @@ export function P2PhoneCode() {
} catch { } catch {
return; return;
} }
} else { // 微信首次绑定:身份尚未确定,去选身份
try { navigate('P3');
const result = await login(phone, code); return;
writeAuth(result.token); }
} catch {
return; let result;
} try {
// 真实 /auth/login:验证码错误 / 过期由后端拒绝并抛出,不会走到下面的跳转
result = await login(phone, code);
} catch {
return;
} }
navigate('P3'); // 服务器给了明确角色就直达对应端;UNASSIGNED 才去选身份
if (!go(land(result))) navigate('P3');
}; };
/** /**

17
components/onboarding/screens/P2QuickLogin.tsx

@ -1,8 +1,13 @@
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors, spacing } from '../../../src/theme'; import { colors, spacing } from '../../../src/theme';
import { CARRIER_LABEL } from '../../../src/constants'; import { CARRIER_LABEL } from '../../../src/constants';
import { useAgreementGate, useCarrierPhone, useLogin, useOnboarding } from '../../../src/hooks'; import {
import { useAuth } from '../../../src/context/AuthContext'; useAgreementGate,
useCarrierPhone,
useLogin,
useLoginLanding,
useOnboarding,
} from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration'; import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
@ -27,8 +32,8 @@ export function P2QuickLogin() {
const { carrierToken, maskedPhone, carrier, ready, loading: fetching, error: fetchError, refresh } = const { carrierToken, maskedPhone, carrier, ready, loading: fetching, error: fetchError, refresh } =
useCarrierPhone(); useCarrierPhone();
const { loginByCarrier, loading: logging, error: loginError } = useLogin(); const { loginByCarrier, loading: logging, error: loginError } = useLogin();
/** 登录态写入:此前本页只跳 P3 不写 token */ /** 登录成功后的会话写入与落地判定 */
const { login: writeAuth } = useAuth(); const { land, go } = useLoginLanding();
const agreed = data.agreed; const agreed = data.agreed;
const carrierName = CARRIER_LABEL[carrier]; const carrierName = CARRIER_LABEL[carrier];
@ -40,14 +45,14 @@ export function P2QuickLogin() {
const handleLogin = async () => { const handleLogin = async () => {
try { try {
const result = await loginByCarrier(carrierToken); const result = await loginByCarrier(carrierToken);
writeAuth(result.token);
// 号码写回引导资料:后续步骤(P2P/P2R 预填、B3 联系人电话)都以这里为准 // 号码写回引导资料:后续步骤(P2P/P2R 预填、B3 联系人电话)都以这里为准
setData({ phone: result.phone }); setData({ phone: result.phone });
// 服务器给了明确角色就直达对应端;UNASSIGNED 才去选身份
if (!go(land(result))) navigate('P3');
} catch { } catch {
// 失败原因由 useLogin 记录,页面下方展示 // 失败原因由 useLogin 记录,页面下方展示
return; return;
} }
navigate('P3');
}; };
/** 运营商认证服务条款由运营商在认证页内提供,本仓库没有该文档,不冒充能打开它 */ /** 运营商认证服务条款由运营商在认证页内提供,本仓库没有该文档,不冒充能打开它 */

30
components/onboarding/screens/P3Identity.tsx

@ -1,18 +1,34 @@
import { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { colors, spacing } from '../../../src/theme'; import { colors, spacing } from '../../../src/theme';
import { useAuth } from '../../../src/context/AuthContext'; import { useAuth } from '../../../src/context/AuthContext';
import { useOnboarding } from '../../../src/hooks'; import { useIdentitySwitch, useOnboarding } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration'; import { Illustration } from '../ui/Illustration';
export function P3Identity() { export function P3Identity() {
const { setData, navigate, back, reset } = useOnboarding(); const { setData, navigate, back, reset } = useOnboarding();
const { logout } = useAuth(); const { logout } = useAuth();
const { switchTo, switching } = useIdentitySwitch();
const [failed, setFailed] = useState<string | null>(null);
const chooseCandidate = () => { /**
* 选求职者:**先由服务端确认身份再往下走**。
* 契约 POST /auth/identity/select 是账号身份的权威来源(并首次下发凭证);
* 早先这里只写本地 data.identity 就跳转,账号在服务端仍是 UNASSIGNED。
*/
const chooseCandidate = async () => {
setFailed(null);
const outcome = await switchTo('STUDENT');
if (!outcome.ok) {
setFailed(outcome.message ?? '身份选择失败,请重试');
return;
}
setData({ identity: 'candidate' }); setData({ identity: 'candidate' });
navigate('P4'); navigate('P4');
}; };
/** 选招聘者:企业资料在 B3 采集,提交时一并调 selectIdentity(契约要求 COMPANY 带 company) */
const chooseRecruiter = () => { const chooseRecruiter = () => {
setData({ identity: 'recruiter' }); setData({ identity: 'recruiter' });
navigate('B3'); navigate('B3');
@ -41,7 +57,11 @@ export function P3Identity() {
<ScreenShell showBack onBack={back}> <ScreenShell showBack onBack={back}>
<Text style={styles.heading}>选择你的身份</Text> <Text style={styles.heading}>选择你的身份</Text>
<Pressable style={styles.card} onPress={chooseCandidate}> <Pressable
style={[styles.card, switching && styles.cardBusy]}
disabled={switching}
onPress={() => void chooseCandidate()}
>
<View style={styles.cardTop}> <View style={styles.cardTop}>
<Illustration name="illustCandidate" size={120} radius={16} /> <Illustration name="illustCandidate" size={120} radius={16} />
</View> </View>
@ -61,6 +81,8 @@ export function P3Identity() {
</View> </View>
</Pressable> </Pressable>
{failed ? <Text style={styles.error}>{failed}</Text> : null}
<Pressable style={styles.logout} onPress={handleLogout}> <Pressable style={styles.logout} onPress={handleLogout}>
<Text style={styles.logoutText}>退出登录</Text> <Text style={styles.logoutText}>退出登录</Text>
</Pressable> </Pressable>
@ -82,6 +104,8 @@ const styles = StyleSheet.create({
}, },
cardBtnOutline: { backgroundColor: colors.white, borderWidth: 1, borderColor: colors.primary }, cardBtnOutline: { backgroundColor: colors.white, borderWidth: 1, borderColor: colors.primary },
cardBtnText: { fontSize: 16, fontWeight: '700', color: colors.textInverse }, cardBtnText: { fontSize: 16, fontWeight: '700', color: colors.textInverse },
cardBusy: { opacity: 0.6 },
error: { marginTop: 12, textAlign: 'center', fontSize: 12, color: colors.dangerText },
or: { textAlign: 'center', color: colors.textHint, marginVertical: spacing.block, fontSize: 13 }, or: { textAlign: 'center', color: colors.textHint, marginVertical: spacing.block, fontSize: 13 },
logout: { marginTop: spacing.section, alignItems: 'center' }, logout: { marginTop: spacing.section, alignItems: 'center' },
logoutText: { fontSize: 13, color: colors.textHint }, logoutText: { fontSize: 13, color: colors.textHint },

196
docs/applications-500-diagnosis.md

@ -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 不足) |

109
docs/联调路径校正映射.md

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

11
src/__tests__/http.test.ts

@ -245,8 +245,13 @@ describe('mapBackendRole(规范 §5 角色映射)', () => {
expect(mapBackendRole('ADMIN')).toBe('admin'); expect(mapBackendRole('ADMIN')).toBe('admin');
}); });
it('认不出来时降级为 candidate,不臆造身份', () => { it('UNASSIGNED(未选身份)返回 null,不得当成求职者放行', () => {
expect(mapBackendRole('UNKNOWN')).toBe('candidate'); // 新手机号首次验证码登录即为 UNASSIGNED,此时必须进身份选择页
expect(mapBackendRole('')).toBe('candidate'); expect(mapBackendRole('UNASSIGNED')).toBeNull();
});
it('认不出来的角色返回 null,不臆造身份', () => {
expect(mapBackendRole('UNKNOWN')).toBeNull();
expect(mapBackendRole('')).toBeNull();
}); });
}); });

80
src/__tests__/password.test.ts

@ -208,6 +208,9 @@ describe('sendSmsCode(发送验证码 · 真实后端)', () => {
}); });
describe('resetPassword(重置 / 首次设置密码)', () => { describe('resetPassword(重置 / 首次设置密码)', () => {
// 与改密域一致:本域默认跑 mock(真实路径由下面的「真实模式」用例单独覆盖)
beforeAll(() => setMockMode(true));
it('验证码不合法时拒绝', async () => { it('验证码不合法时拒绝', async () => {
await expect( await expect(
resetPassword({ phone: '13800138000', code: '12', password: 'abc12345' }), resetPassword({ phone: '13800138000', code: '12', password: 'abc12345' }),
@ -220,14 +223,45 @@ describe('resetPassword(重置 / 首次设置密码)', () => {
).rejects.toThrow(PASSWORD_RULE_LABEL['need-letter-digit']); ).rejects.toThrow(PASSWORD_RULE_LABEL['need-letter-digit']);
}); });
it('参数合法时直接下发登录态,用户无需再登录一次', async () => { it('参数合法时返回密码操作结果,**不返回任何 Token**', async () => {
const result = await resetPassword({ const result = await resetPassword({
phone: '13800138000', phone: '13800138000',
code: '1234', code: '1234',
password: 'abc12345', password: 'abc12345',
}); });
expect(result.phone).toBe('13800138000'); // 契约 /auth/password/reset 只回 {passwordConfigured, reauthenticationRequired}
expect(result.token).toContain('13800138000'); expect(result.passwordConfigured).toBe(true);
expect(result).not.toHaveProperty('token');
});
it('真实模式:POST /auth/password/reset,body 用契约字段 smsCode / newPassword', async () => {
setMockMode(false);
(global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async () =>
makeResponse(200, {
code: 200,
message: 'ok',
data: { passwordConfigured: true, reauthenticationRequired: true },
requestId: 'r',
timestamp: 't',
}),
);
const result = await resetPassword({
phone: '13800138000',
code: '1234',
password: 'abc12345',
});
const fetchMock = (global as unknown as { fetch: jest.Mock }).fetch as jest.Mock;
const [calledUrl, calledInit] = fetchMock.mock.calls[0];
expect(calledUrl).toContain('/auth/password/reset');
expect(calledInit.method).toBe('POST');
// 契约字段名是 smsCode / newPassword,发 code / password 会被判为缺失
expect(JSON.parse(String(calledInit.body))).toEqual({
phone: '13800138000',
smsCode: '1234',
newPassword: 'abc12345',
});
expect(result.reauthenticationRequired).toBe(true);
setMockMode(true);
}); });
}); });
@ -252,9 +286,41 @@ describe('changePassword(已登录态改密)', () => {
).rejects.toThrow(PASSWORD_RULE_LABEL['same-as-old']); ).rejects.toThrow(PASSWORD_RULE_LABEL['same-as-old']);
}); });
it('参数合法时成功', async () => { it('参数合法时返回密码操作结果', async () => {
await expect( const result = await changePassword({
changePassword({ oldPassword: 'old12345', newPassword: 'abc12345', confirmPassword: 'abc12345' }), oldPassword: 'old12345',
).resolves.toBeUndefined(); newPassword: 'abc12345',
confirmPassword: 'abc12345',
});
expect(result.passwordConfigured).toBe(true);
});
it('真实模式:POST /auth/password/change,原密码字段名是 currentPassword', async () => {
setMockMode(false);
(global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async () =>
makeResponse(200, {
code: 200,
message: 'ok',
data: { passwordConfigured: true, reauthenticationRequired: false },
requestId: 'r',
timestamp: 't',
}),
);
const result = await changePassword({
oldPassword: 'old12345',
newPassword: 'abc12345',
confirmPassword: 'abc12345',
});
const fetchMock = (global as unknown as { fetch: jest.Mock }).fetch as jest.Mock;
const [calledUrl, calledInit] = fetchMock.mock.calls[0];
expect(calledUrl).toContain('/auth/password/change');
expect(calledInit.method).toBe('POST');
// 发 oldPassword 会让后端认为原密码为空,改密必然失败
expect(JSON.parse(String(calledInit.body))).toEqual({
currentPassword: 'old12345',
newPassword: 'abc12345',
});
expect(result.reauthenticationRequired).toBe(false);
setMockMode(true);
}); });
}); });

81
src/api/auth.ts

@ -1,4 +1,4 @@
import { request, isMockMode } from './http'; import { request, isMockMode, toExpiresAt } from './http';
import type { AppRole } from '../types'; import type { AppRole } from '../types';
/* ===================== 鉴权接口(真实后端) ===================== /* ===================== 鉴权接口(真实后端) =====================
@ -6,15 +6,23 @@ import type { AppRole } from '../types';
* 客户端不提交 role / userId / verified / passwordHash(后端生成)。 * 客户端不提交 role / userId / verified / passwordHash(后端生成)。
*/ */
/** 后端下发的角色值(规范 §5) */ /**
export type BackendRole = 'STUDENT' | 'COMPANY' | 'ADMIN'; * 后端下发的角色值(规范 §5)。
* `UNASSIGNED` 是「已注册但还没选身份」的统一账号状态(新手机号首次验证码登录即此态),
* 后端契约 `POST /auth/login` 明写:新手机号创建 UNASSIGNED 账号,此时
* `identitySelectionRequired=true`,前端必须进入首次身份选择。
*/
export type BackendRole = 'STUDENT' | 'COMPANY' | 'ADMIN' | 'UNASSIGNED';
/** /**
* 后端角色 → 应用内部角色(§5:以 /auth/me 返回为准)。 * 后端角色 → 应用内部角色(§5:以 /auth/me 返回为准)。
* STUDENT→candidate(求职者)、COMPANY→enterprise(企业)、ADMIN→admin(管理员)。 * STUDENT→candidate(求职者)、COMPANY→enterprise(企业)、ADMIN→admin(管理员)。
* 认不出来时降级为 candidate,不臆造身份。 *
* 认不出来一律返回 **null**,不再降级成 candidate:
* UNASSIGNED 账号被当成求职者,用户会以「还没选身份」的账号直接落到求职端主界面;
* 后端将来新增角色时同样会被静默错认。null 会强制调用方去走身份选择分支。
*/ */
export function mapBackendRole(role: string): AppRole { export function mapBackendRole(role: string): AppRole | null {
switch (role) { switch (role) {
case 'STUDENT': case 'STUDENT':
return 'candidate'; return 'candidate';
@ -23,7 +31,7 @@ export function mapBackendRole(role: string): AppRole {
case 'ADMIN': case 'ADMIN':
return 'admin'; return 'admin';
default: default:
return 'candidate'; return null;
} }
} }
@ -43,13 +51,17 @@ export interface AuthLoginData extends AuthTokens {
userId: number; userId: number;
phone: string; phone: string;
nickname: string; nickname: string;
/** 后端原始角色值;已由调用方经 mapBackendRole 映射,UNASSIGNED 表示还需选身份 */
role: BackendRole; role: BackendRole;
} /**
* 是否还需完成首次身份选择(后端契约 `identitySelectionRequired`)。
/** 后端 expiresIn(秒)→ expiresAt(毫秒,从现在起算) */ * 为 true 时必须进身份选择页,不能直接落地任何一端。
function toExpiresAt(expiresIn?: number): number | undefined { */
if (!expiresIn || expiresIn <= 0) return undefined; identitySelectionRequired?: boolean;
return Date.now() + expiresIn * 1000; /** 企业 id(选 COMPANY 或已是企业账号时下发) */
companyId?: number;
/** 企业入驻状态(onboardingStatus) */
onboardingStatus?: string;
} }
export interface SmsSendPayload { export interface SmsSendPayload {
@ -105,6 +117,9 @@ export async function loginByPassword(payload: PasswordLoginPayload): Promise<Au
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
expiresIn?: number; expiresIn?: number;
identitySelectionRequired?: boolean;
companyId?: number;
onboardingStatus?: string;
}>('/auth/password/login', { method: 'POST', auth: false, body: payload }); }>('/auth/password/login', { method: 'POST', auth: false, body: payload });
return { return {
userId: raw.userId, userId: raw.userId,
@ -114,6 +129,9 @@ export async function loginByPassword(payload: PasswordLoginPayload): Promise<Au
accessToken: raw.accessToken, accessToken: raw.accessToken,
refreshToken: raw.refreshToken, refreshToken: raw.refreshToken,
expiresAt: toExpiresAt(raw.expiresIn), expiresAt: toExpiresAt(raw.expiresIn),
identitySelectionRequired: raw.identitySelectionRequired,
companyId: raw.companyId,
onboardingStatus: raw.onboardingStatus,
}; };
} }
@ -129,13 +147,22 @@ function mockAuthTokens(nonce: string): AuthTokens {
export interface SmsLoginPayload { export interface SmsLoginPayload {
phone: string; phone: string;
code: string; code: string;
/** 契约可选字段;登录场景固定 login,企业注册走 company-register */
purpose?: 'login' | 'company-register' | 'password-reset';
} }
/** /**
* 短信验证码登录(§4 步骤2B)。 * 短信验证码登录(§4 步骤2B / 契约 `POST /auth/login`)。
* 后端自动创建 STUDENT;已有账号返回其实际角色(不一定是学生)。 *
* 契约语义:**新手机号创建 UNASSIGNED 统一账号并令 `identitySelectionRequired=true`**,
* 旧角色保持不变。所以「登录成功」不等于「可以进主界面」——调用方必须先看
* `identitySelectionRequired` 与 `role` 再决定跳身份选择页还是落地。
* 验证码错误 / 过期由后端返回错误码,本函数原样抛出,绝不吞掉失败伪造成功。
*/ */
export async function loginBySms(payload: SmsLoginPayload): Promise<AuthLoginData> { export async function loginBySms(payload: SmsLoginPayload): Promise<AuthLoginData> {
if (!isValidPhone(payload.phone)) {
throw new Error('手机号格式不正确');
}
if (isMockMode()) { if (isMockMode()) {
const mock = mockAuthTokens(`sms_${payload.phone}`); const mock = mockAuthTokens(`sms_${payload.phone}`);
return { return {
@ -144,6 +171,7 @@ export async function loginBySms(payload: SmsLoginPayload): Promise<AuthLoginDat
phone: payload.phone, phone: payload.phone,
nickname: 'Mock 用户', nickname: 'Mock 用户',
role: 'STUDENT', role: 'STUDENT',
identitySelectionRequired: false,
}; };
} }
const raw = await request<{ const raw = await request<{
@ -154,7 +182,14 @@ export async function loginBySms(payload: SmsLoginPayload): Promise<AuthLoginDat
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
expiresIn?: number; expiresIn?: number;
}>('/auth/login', { method: 'POST', auth: false, body: payload }); identitySelectionRequired?: boolean;
companyId?: number;
onboardingStatus?: string;
}>('/auth/login', {
method: 'POST',
auth: false,
body: { phone: payload.phone, code: payload.code, purpose: payload.purpose ?? 'login' },
});
return { return {
userId: raw.userId, userId: raw.userId,
phone: raw.phone, phone: raw.phone,
@ -163,6 +198,9 @@ export async function loginBySms(payload: SmsLoginPayload): Promise<AuthLoginDat
accessToken: raw.accessToken, accessToken: raw.accessToken,
refreshToken: raw.refreshToken, refreshToken: raw.refreshToken,
expiresAt: toExpiresAt(raw.expiresIn), expiresAt: toExpiresAt(raw.expiresIn),
identitySelectionRequired: raw.identitySelectionRequired,
companyId: raw.companyId,
onboardingStatus: raw.onboardingStatus,
}; };
} }
@ -170,8 +208,11 @@ export async function loginBySms(payload: SmsLoginPayload): Promise<AuthLoginDat
export interface CurrentUser { export interface CurrentUser {
/** 用户 id(后端为 int64,前端按字符串处理避免精度丢失) */ /** 用户 id(后端为 int64,前端按字符串处理避免精度丢失) */
id: string; id: string;
/** 已由后端角色映射为应用内部角色(candidate / enterprise / admin) */ /**
role: AppRole; * 已由后端角色映射为应用内部角色;**null 表示账号尚未选身份(UNASSIGNED)或角色未知**。
* 调用方拿到 null 必须走身份选择分支,不能当作求职者放行。
*/
role: AppRole | null;
/** 手机号 */ /** 手机号 */
phone: string; phone: string;
/** 昵称 */ /** 昵称 */
@ -204,7 +245,7 @@ export async function fetchCurrentUser(): Promise<CurrentUser> {
verified: boolean; verified: boolean;
active: boolean; active: boolean;
createdAt?: string; createdAt?: string;
}>('/auth/me', { method: 'GET' }); }>('/auth/me', { method: 'GET', auth: true });
return { return {
id: String(raw.id), id: String(raw.id),
role: mapBackendRole(raw.role), role: mapBackendRole(raw.role),
@ -335,6 +376,7 @@ export async function selectIdentity(payload: IdentitySelectionPayload): Promise
const raw = await request<{ const raw = await request<{
userId: number; userId: number;
role: BackendRole; role: BackendRole;
nickname?: string;
identitySelectionRequired: boolean; identitySelectionRequired: boolean;
companyId?: number; companyId?: number;
onboardingStatus?: string; onboardingStatus?: string;
@ -345,8 +387,9 @@ export async function selectIdentity(payload: IdentitySelectionPayload): Promise
}>('/auth/identity/select', { method: 'POST', auth: true, body: payload }); }>('/auth/identity/select', { method: 'POST', auth: true, body: payload });
return { return {
userId: raw.userId, userId: raw.userId,
// 契约未下发 phone / nickname 时留空,不再回填 'Mock 用户' 这种假值
phone: '', phone: '',
nickname: 'Mock 用户', nickname: raw.nickname ?? '',
role: raw.role, role: raw.role,
accessToken: raw.accessToken, accessToken: raw.accessToken,
refreshToken: raw.refreshToken, refreshToken: raw.refreshToken,

37
src/api/http.ts

@ -97,6 +97,35 @@ export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler; unauthorizedHandler = handler;
} }
/**
* 刷新成功回调:由 AuthContext 注册。
*
* 刷新发生在请求层内部(401 拦截),AuthContext 并不知情 —— 不通知它的话,
* token-store 已换成新 Token,而 AuthContext 里仍是旧 token + 旧 expiresAt,
* 两份状态从此分叉:`isTokenValid` 会用过期时间把已续期的用户判成未登录。
* 故刷新成功后回调,让 AuthContext 把同一份新凭证写入自己的 state。
*/
let sessionRefreshedHandler:
| ((accessToken: string, refreshToken: string, expiresAt?: number) => void)
| null = null;
export function setSessionRefreshedHandler(
handler: (accessToken: string, refreshToken: string, expiresAt?: number) => void,
): void {
sessionRefreshedHandler = handler;
}
/**
* 后端 `expiresIn`(秒级 TTL)→ 本地绝对过期时间戳(毫秒)。
*
* 契约 `/auth/login`、`/auth/refresh`、`/auth/identity/select` 下发的都是 `expiresIn`,
* 早先请求层读的是并不存在的 `expiresAt`,于是每次刷新后到期时间都丢失。
* 放在 http 层是因为 auth.ts 依赖本文件(反向 import 会成环),两边共用这一份。
*/
export function toExpiresAt(expiresIn?: number): number | undefined {
if (!expiresIn || expiresIn <= 0) return undefined;
return Date.now() + expiresIn * 1000;
}
/** 单飞刷新闸门:并发 401 只触发一次刷新(§4 多标签并发互斥的移动端等价物) */ /** 单飞刷新闸门:并发 401 只触发一次刷新(§4 多标签并发互斥的移动端等价物) */
let refreshPromise: Promise<boolean> | null = null; let refreshPromise: Promise<boolean> | null = null;
@ -144,17 +173,21 @@ async function doRefresh(): Promise<boolean> {
unauthorizedHandler?.(); unauthorizedHandler?.();
return false; return false;
} }
// 契约返回 expiresIn(秒),不是 expiresAt;转换后才是本地到期时间戳
const result = await rawRequest<{ const result = await rawRequest<{
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
expiresAt?: number; expiresIn?: number;
}>('/auth/refresh', { }>('/auth/refresh', {
method: 'POST', method: 'POST',
body: { refreshToken: tokens.refreshToken }, body: { refreshToken: tokens.refreshToken },
auth: false, auth: false,
_isRefresh: true, _isRefresh: true,
}); });
await setTokens(result.accessToken, result.refreshToken, result.expiresAt); const expiresAt = toExpiresAt(result.expiresIn);
await setTokens(result.accessToken, result.refreshToken, expiresAt);
// 同步通知 AuthContext,避免「token-store 已续期、Context 还以为过期」的分叉
sessionRefreshedHandler?.(result.accessToken, result.refreshToken, expiresAt);
return true; return true;
} catch { } catch {
await clearTokens(); await clearTokens();

232
src/context/AuthContext.tsx

@ -1,63 +1,92 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react';
import { storageGet, storageSet } from '../lib/storage'; import { storageGet, storageSet } from '../lib/storage';
import { TOKEN_TTL_MS } from '../constants'; import { TOKEN_TTL_MS } from '../constants';
import { clearTokens, setTokens, hydrateTokens } from '../lib/token-store'; import { clearTokens, setTokens, hydrateTokens } from '../lib/token-store';
import { setUnauthorizedHandler } from '../api/http'; import { setUnauthorizedHandler, setSessionRefreshedHandler } from '../api/http';
import { fetchCurrentUser, mapBackendRole } from '../api/auth';
import type { AppRole } from '../types';
/* ===================== 鉴权(引导/登录)上下文 ===================== /* ===================== 鉴权(引导/登录)上下文 =====================
* 管理「引导页是否看过」「是否已登录」「登录令牌」「分端标记」。 * 管理「引导页是否看过」「是否已登录」「会话凭证」「服务器下发的角色」。
* 与 RoleContext 配合形成完整漏斗:
* 首次启动 → 引导页 → 登录页 → 选择身份(identity-select) → 分端设置 → 对应端
* - 求职者:求职意向设置(job-preference) → 求职端 (tabs)
* - 招聘者:企业认证(enterprise-certification) → 企业端 (enterprise)
* 两个分端设置步骤均允许跳过,设置完成后置位对应标记,不再重复强制。
* *
* 持久化:已接入 @react-native-async-storage/async-storage, * 三条硬规则(对应本轮修复的会话/身份缺陷):
* 五字段以 JSON 整体存入 'auth' 键,App 重启后自动恢复引导/登录/身份态。 * 1. **只有一个会话写入入口**:`applySession`。它同时写 access + refresh + 到期时间 +
* 角色,并同步 token-store。此前的 `login(token)` 只写 accessToken、不写 refresh、
* 不碰 token-store —— 页面看起来「登录成功」,真实请求却一个 Bearer 都带不出去。
* 2. **角色只认服务器**:本地不做任何身份推断,`role` 来自登录/选身份/`/auth/me` 的响应。
* 后端返回 UNASSIGNED 或认不出的角色 → `role` 为 null → 页面必须进身份选择,
* 不能当求职者放行。
* 3. **接口核对优先于本地缓存**:`verifySession()`(调 `/auth/me`)失败会抛出,
* 调用方要么重试要么要求重新登录,绝不静默回退本地角色并跳进业务页面。
*
* 持久化:整体以 JSON 存入 'auth' 键,App 重启后自动恢复。
*/ */
/** 建立会话所需的全部信息(双 Token + 服务器角色) */
export interface SessionPayload {
accessToken: string;
refreshToken: string;
/** 到期时间戳(毫秒);不传表示后端未下发 TTL */
expiresAt?: number;
/** 后端原始角色值(STUDENT / COMPANY / ADMIN / UNASSIGNED) */
backendRole?: string;
/** 是否还需完成首次身份选择 */
identitySelectionRequired?: boolean;
}
export interface AuthContextValue { export interface AuthContextValue {
/** 引导页是否已看完(首次启动展示引导页) */ /** 引导页是否已看完(首次启动展示引导页) */
onboardingSeen: boolean; onboardingSeen: boolean;
/** 是否已登录 */ /** 是否已登录 */
isLoggedIn: boolean; isLoggedIn: boolean;
/** 登录令牌(mock 态为占位串) */ /** 访问令牌 */
token: string | null; token: string | null;
/** 刷新令牌(双 Token 方案;mock 态为 null) */ /** 刷新令牌 */
refreshToken: string | null; refreshToken: string | null;
/** 令牌过期时间戳(毫秒);null 表示无有效令牌 */ /** 令牌过期时间戳(毫秒);null 表示未知或已清空 */
tokenExpiresAt: number | null; tokenExpiresAt: number | null;
/** /**
* 本地令牌是否仍然有效(已登录且未过期)。 * 本地令牌是否仍然有效(已登录且未过期)。
* 微信登录「第 2~N 次」场景依赖此值:有效则免微信授权直接进主界面。 * 到期时间未知(后端没给 expiresIn)时按「有效」处理:宁可由一次真实请求的 401
* 来判定失效,也不要因为拿不到 TTL 就把已登录用户挡在门外。
*/ */
isTokenValid: boolean; isTokenValid: boolean;
/** 求职端:求职意向是否已设置(设置后不再强制进入设置页) */
candidateSetupDone: boolean;
/** 企业端:是否已提交/完成企业认证(走完该步骤后不再强制) */
enterpriseVerified: boolean;
/** /**
* 本地存储是否已读取完毕。 * 服务器下发的角色;**null = 未选身份(UNASSIGNED)或角色无法识别**。
* 冷启动时 AsyncStorage 读取是异步的,在读取完成前上述字段仍是默认值; * 页面拿到 null 必须走身份选择,不得自行推断。
* 入口页必须等该标记为 true 再决定跳转,否则已登录用户会闪现引导页。
*/ */
role: AppRole | null;
/** 后端要求完成首次身份选择(登录响应 identitySelectionRequired) */
identitySelectionRequired: boolean;
/**
* 角色是否已与服务器核对过(`verifySession` 成功过)。
* 冷启动时本地 role 只是上次的缓存,未核对前不应据此放行进业务页。
*/
isRoleVerified: boolean;
/** 求职端:求职意向是否已设置 */
candidateSetupDone: boolean;
/** 企业端:是否已提交/完成企业认证 */
enterpriseVerified: boolean;
/** 本地存储是否已读取完毕 */
isHydrated: boolean; isHydrated: boolean;
/** 标记引导页已看完 */ /** 标记引导页已看完 */
completeOnboarding: () => void; completeOnboarding: () => void;
/** **唯一会话写入入口**:双 Token + 服务器角色一起落库 */
applySession: (session: SessionPayload) => void;
/** /**
* 登录成功,写入 token。 * 用 `/auth/me` 核对当前角色并回写。
* @param token 登录令牌 * @returns 服务器角色(可能为 null = 未选身份)
* @param expiresAt 过期时间戳(毫秒);不传时按 TOKEN_TTL_MS 推算 * @throws 请求失败时抛出,调用方负责重试或要求重新登录
*/ */
login: (token: string, expiresAt?: number) => void; verifySession: () => Promise<AppRole | null>;
/** /** 退出登录:清空令牌、角色与分端标记 */
* 真实登录成功:写入双 Token 并持久化(access + refresh)。
* @param accessToken 业务请求用访问令牌
* @param refreshToken 仅提交给 /auth/refresh 的刷新令牌
* @param expiresAt accessToken 过期时间戳(毫秒),后端可能不返回
*/
setSession: (accessToken: string, refreshToken: string, expiresAt?: number) => void;
/** 退出登录,清空令牌 */
logout: () => void; logout: () => void;
/** 标记求职意向已设置 */ /** 标记求职意向已设置 */
completeCandidateSetup: () => void; completeCandidateSetup: () => void;
@ -73,6 +102,9 @@ interface MemoryAuth {
token: string | null; token: string | null;
refreshToken: string | null; refreshToken: string | null;
tokenExpiresAt: number | null; tokenExpiresAt: number | null;
role: AppRole | null;
identitySelectionRequired: boolean;
isRoleVerified: boolean;
candidateSetupDone: boolean; candidateSetupDone: boolean;
enterpriseVerified: boolean; enterpriseVerified: boolean;
} }
@ -85,6 +117,9 @@ const DEFAULT_AUTH: MemoryAuth = {
token: null, token: null,
refreshToken: null, refreshToken: null,
tokenExpiresAt: null, tokenExpiresAt: null,
role: null,
identitySelectionRequired: false,
isRoleVerified: false,
candidateSetupDone: false, candidateSetupDone: false,
enterpriseVerified: false, enterpriseVerified: false,
}; };
@ -101,18 +136,36 @@ function loadAuth(): Promise<MemoryAuth | null> {
return storageGet(AUTH_KEY).then((raw) => { return storageGet(AUTH_KEY).then((raw) => {
if (!raw) return null; if (!raw) return null;
try { try {
return JSON.parse(raw) as MemoryAuth; // 与 DEFAULT_AUTH 合并:老版本存的 JSON 缺少新字段时补默认值,
// 否则 role 之类的新字段是 undefined,与 null 的语义判断会走偏
return { ...DEFAULT_AUTH, ...(JSON.parse(raw) as Partial<MemoryAuth>) };
} catch { } catch {
return null; return null;
} }
}); });
} }
/** 清空会话(登出 / 401 刷新失败):令牌、角色、分端标记一并清 */
function clearedSession(state: MemoryAuth): MemoryAuth {
return {
...state,
isLoggedIn: false,
token: null,
refreshToken: null,
tokenExpiresAt: null,
role: null,
identitySelectionRequired: false,
isRoleVerified: false,
candidateSetupDone: false,
enterpriseVerified: false,
};
}
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<MemoryAuth>(memoryAuth); const [state, setState] = useState<MemoryAuth>(memoryAuth);
const [isHydrated, setIsHydrated] = useState(false); const [isHydrated, setIsHydrated] = useState(false);
// 启动时从 async-storage 恢复登录/引导/分端标记;同时回填双 Token 内存态 // 启动时从 async-storage 恢复;同时回填双 Token 内存态
useEffect(() => { useEffect(() => {
let active = true; let active = true;
Promise.all([loadAuth(), hydrateTokens()]) Promise.all([loadAuth(), hydrateTokens()])
@ -129,84 +182,94 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// 401 清会话回调:刷新失败 / 无 refreshToken 时由请求层调用 // 401 清会话回调:刷新失败 / 无 refreshToken 时由请求层调用
setUnauthorizedHandler(() => { setUnauthorizedHandler(() => {
if (!active) return; if (!active) return;
const loggedOut: MemoryAuth = { const loggedOut = clearedSession(memoryAuth);
...memoryAuth,
isLoggedIn: false,
token: null,
refreshToken: null,
tokenExpiresAt: null,
candidateSetupDone: false,
enterpriseVerified: false,
};
persistAuth(loggedOut); persistAuth(loggedOut);
setState(loggedOut); setState(loggedOut);
void clearTokens(); void clearTokens();
}); });
// 刷新成功回调:把请求层续期后的新凭证同步到 Context,避免两份会话状态分叉
setSessionRefreshedHandler((accessToken, refreshToken, expiresAt) => {
if (!active) return;
const next: MemoryAuth = {
...memoryAuth,
isLoggedIn: true,
token: accessToken,
refreshToken,
tokenExpiresAt: expiresAt ?? memoryAuth.tokenExpiresAt,
};
persistAuth(next);
setState(next);
});
return () => { return () => {
active = false; active = false;
}; };
}, []); }, []);
const completeOnboarding = () => { const completeOnboarding = useCallback(() => {
const next = { ...state, onboardingSeen: true }; const next = { ...memoryAuth, onboardingSeen: true };
persistAuth(next); persistAuth(next);
setState(next); setState(next);
}; }, []);
const login = (token: string, expiresAt?: number) => { const applySession = useCallback((session: SessionPayload) => {
// 后端没下发 expiresIn 时保留原有到期时间;两者都没有则记为 null(未知)
const prev = memoryAuth;
const expiresAt = session.expiresAt ?? prev.tokenExpiresAt ?? null;
const mappedRole = session.backendRole ? mapBackendRole(session.backendRole) : null;
const next: MemoryAuth = { const next: MemoryAuth = {
...state, ...prev,
isLoggedIn: true, isLoggedIn: true,
token, token: session.accessToken,
tokenExpiresAt: expiresAt ?? Date.now() + TOKEN_TTL_MS, refreshToken: session.refreshToken,
tokenExpiresAt: expiresAt,
// 登录响应带角色时以它为准;不带(如刷新)则保留原角色
role: session.backendRole ? mappedRole : prev.role,
identitySelectionRequired: session.identitySelectionRequired ?? false,
isRoleVerified: session.backendRole ? true : prev.isRoleVerified,
}; };
persistAuth(next); persistAuth(next);
setState(next); setState(next);
}; void setTokens(session.accessToken, session.refreshToken, expiresAt ?? undefined);
}, []);
const setSession = (accessToken: string, refreshToken: string, expiresAt?: number) => { const verifySession = useCallback(async (): Promise<AppRole | null> => {
const me = await fetchCurrentUser();
const next: MemoryAuth = { const next: MemoryAuth = {
...state, ...memoryAuth,
isLoggedIn: true, role: me.role,
token: accessToken, isRoleVerified: true,
refreshToken, // 服务器说角色还没定(UNASSIGNED)就等同「还需要选身份」
tokenExpiresAt: expiresAt ?? Date.now() + TOKEN_TTL_MS, identitySelectionRequired: me.role === null,
}; };
persistAuth(next); persistAuth(next);
setState(next); setState(next);
void setTokens(accessToken, refreshToken, expiresAt); return me.role;
}; }, []);
const logout = () => { const logout = useCallback(() => {
const next: MemoryAuth = { const next = clearedSession(memoryAuth);
...state,
isLoggedIn: false,
token: null,
refreshToken: null,
tokenExpiresAt: null,
candidateSetupDone: false,
enterpriseVerified: false,
};
persistAuth(next); persistAuth(next);
setState(next); setState(next);
void clearTokens(); void clearTokens();
}; }, []);
const completeCandidateSetup = () => { const completeCandidateSetup = useCallback(() => {
const next = { ...state, candidateSetupDone: true }; const next = { ...memoryAuth, candidateSetupDone: true };
persistAuth(next); persistAuth(next);
setState(next); setState(next);
}; }, []);
const completeEnterpriseVerify = () => { const completeEnterpriseVerify = useCallback(() => {
const next = { ...state, enterpriseVerified: true }; const next = { ...memoryAuth, enterpriseVerified: true };
persistAuth(next); persistAuth(next);
setState(next); setState(next);
}; }, []);
// 派生值,不入库:已登录且过期时间晚于当前时间才算有效 // 派生值,不入库:已登录且有令牌,且(到期时间未知 或 尚未过期)
const isTokenValid = const isTokenValid =
state.isLoggedIn && state.token !== null && (state.tokenExpiresAt ?? 0) > Date.now(); state.isLoggedIn &&
state.token !== null &&
(state.tokenExpiresAt === null || state.tokenExpiresAt > Date.now());
return ( return (
<AuthContext.Provider <AuthContext.Provider
@ -215,8 +278,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isHydrated, isHydrated,
isTokenValid, isTokenValid,
completeOnboarding, completeOnboarding,
login, applySession,
setSession, verifySession,
logout, logout,
completeCandidateSetup, completeCandidateSetup,
completeEnterpriseVerify, completeEnterpriseVerify,
@ -234,3 +297,10 @@ export function useAuth(): AuthContextValue {
} }
return ctx; return ctx;
} }
/**
* 会话凭证的默认有效期兜底。
* 后端未下发 expiresIn 时不再编造到期时间(那会让 `isTokenValid` 拿一个假值做判断),
* 保留给需要展示「登录有效期」的页面使用。
*/
export const DEFAULT_SESSION_TTL_MS = TOKEN_TTL_MS;

83
src/context/RoleContext.tsx

@ -1,85 +1,36 @@
import { import { createContext, useContext, type ReactNode } from 'react';
createContext, import { useAuth } from './AuthContext';
useContext,
useEffect,
useState,
type ReactNode,
} from 'react';
import { storageGet, storageSet, storageRemove } from '../lib/storage';
import type { AppRole } from '../types'; import type { AppRole } from '../types';
/* ===================== 角色(端)上下文 ===================== /* ===================== 角色(端)上下文 =====================
* 区分「求职端 candidate」与「企业端 enterprise」两套管路。 * **角色只有一个来源:服务器**。本 Context 只是 AuthContext 里服务器角色的只读视图,
* - 进入 (tabs) 视为求职端,进入 (enterprise) 视为企业端; * 供页面与布局按端渲染;它不再提供任何本地写入方法。
* - identity-select 选定身份时写入 role,两端布局挂载时也会兜底校正。
* *
* 持久化:当前为会话内(内存)实现,App 重启后回到 null(重新选择身份)。 * 为什么删掉 setRole(原来由两端布局在 useFocusEffect 里调用):
* 若已安装 @react-native-async-storage/async-storage,可将文件底部 * 布局获得焦点时把角色写成「这一端」等于「用户点了哪个 Tab,账号身份就变成什么」——
* persistRole / loadRole 替换为真实读写,即可跨重启记住当前端。 * 一个 STUDENT 账号只要切到企业端 Tab,本地角色就变成 enterprise,
* 与服务端事实相反,且下次冷启动会把他直接放进企业端。
* 页面上确实需要「换端」的入口改走 `useIdentitySwitch()`,由服务端裁决。
*/ */
/** 角色类型统一由 types 层定义,此处 re-export 便于页面从 context 一并导入 */
export type { AppRole };
interface RoleContextValue { interface RoleContextValue {
/** 服务器下发的角色;null = 未选身份或角色未知 */
role: AppRole | null; role: AppRole | null;
/** /** 角色是否已与服务器核对过(未核对前不适合据本地缓存放行) */
* 本地存储中的角色是否已读取完毕。 isRoleVerified: boolean;
* 与 AuthContext.isHydrated 配套:入口页需等两者都就绪再决定跳哪一端, /** 本地存储是否已读取完毕(与 AuthContext.isHydrated 同源) */
* 否则已登录用户冷启动时会因 role 仍为 null 被错误重定向到注册流程。
*/
isHydrated: boolean; isHydrated: boolean;
setRole: (role: AppRole) => void;
clearRole: () => void;
} }
const RoleContext = createContext<RoleContextValue | undefined>(undefined); const RoleContext = createContext<RoleContextValue | undefined>(undefined);
const ROLE_KEY = 'app_role'; /** 角色类型统一由 types 层定义,此处 re-export 便于页面从 context 一并导入 */
export type { AppRole };
function persistRole(role: AppRole | null): void {
if (role) {
void storageSet(ROLE_KEY, role);
} else {
void storageRemove(ROLE_KEY);
}
}
function loadRole(): Promise<AppRole | null> {
return storageGet(ROLE_KEY).then((stored) => (stored ? (stored as AppRole) : null));
}
export function RoleProvider({ children }: { children: ReactNode }) { export function RoleProvider({ children }: { children: ReactNode }) {
const [role, setRoleState] = useState<AppRole | null>(null); const { role, isRoleVerified, isHydrated } = useAuth();
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
let active = true;
loadRole()
.then((stored) => {
if (active && stored) setRoleState(stored as AppRole);
})
.finally(() => {
// 无论读取成功、为空还是异常都必须置位,避免入口页永久停在占位态
if (active) setIsHydrated(true);
});
return () => {
active = false;
};
}, []);
const setRole = (next: AppRole) => {
setRoleState(next);
persistRole(next);
};
const clearRole = () => {
setRoleState(null);
persistRole(null);
};
return ( return (
<RoleContext.Provider value={{ role, isHydrated, setRole, clearRole }}> <RoleContext.Provider value={{ role, isRoleVerified, isHydrated }}>
{children} {children}
</RoleContext.Provider> </RoleContext.Provider>
); );

4
src/hooks/index.ts

@ -77,6 +77,10 @@ export { useSkillLearningPath } from './useSkillLearningPath';
export { useTalentRecommendation } from './useTalentRecommendation'; export { useTalentRecommendation } from './useTalentRecommendation';
export { useWeightSettings } from './useWeightSettings'; export { useWeightSettings } from './useWeightSettings';
export { useLogin } from './useLogin'; export { useLogin } from './useLogin';
export { useLoginLanding } from './useLoginLanding';
export type { LandingTarget } from './useLoginLanding';
export { useIdentitySwitch } from './useIdentitySwitch';
export type { IdentitySwitchOutcome } from './useIdentitySwitch';
export { OnboardingProvider, useOnboarding } from './useOnboardingFlow'; export { OnboardingProvider, useOnboarding } from './useOnboardingFlow';
export { useRegisterFinish } from './useRegisterFinish'; export { useRegisterFinish } from './useRegisterFinish';
export { useWechatLogin, type PendingBindPhone, type WechatLoginState } from './useWechatLogin'; export { useWechatLogin, type PendingBindPhone, type WechatLoginState } from './useWechatLogin';

70
src/hooks/useIdentitySwitch.ts

@ -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 };
}

56
src/hooks/useLoginLanding.ts

@ -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 };
}

7
src/hooks/usePasswordLogin.ts

@ -1,9 +1,10 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { loginByPassword, type AuthTokens } from '../api'; import { loginByPassword } from '../api';
import type { AuthLoginData } from '../api/auth';
export interface UsePasswordLoginReturn { export interface UsePasswordLoginReturn {
/** 手机号 + 密码登录,成功返回双 Token */ /** 手机号 + 密码登录,成功返回完整会话(双 Token + 服务器角色) */
login: (phone: string, password: string) => Promise<AuthTokens>; login: (phone: string, password: string) => Promise<AuthLoginData>;
/** 登录进行中 */ /** 登录进行中 */
loading: boolean; loading: boolean;
/** 登录失败的错误(用于页面提示) */ /** 登录失败的错误(用于页面提示) */

6
src/hooks/usePasswordReset.ts

@ -4,13 +4,13 @@ import {
resetPassword, resetPassword,
validatePassword, validatePassword,
validatePasswordConfirm, validatePasswordConfirm,
type LoginResult, type PasswordOperationResult,
} from '../api'; } from '../api';
import type { PasswordResetPayload } from '../types'; import type { PasswordResetPayload } from '../types';
export interface UsePasswordResetReturn { export interface UsePasswordResetReturn {
/** 重置 / 首次设置密码,成功返回登录结果(后端直接下发登录态) */ /** 重置 / 首次设置密码;返回后端密码操作结果(**不含 Token**) */
reset: (payload: PasswordResetPayload) => Promise<LoginResult>; reset: (payload: PasswordResetPayload) => Promise<PasswordOperationResult>;
/** 提交进行中 */ /** 提交进行中 */
loading: boolean; loading: boolean;
/** 失败原因(用于页面提示) */ /** 失败原因(用于页面提示) */

50
src/hooks/useRegisterFinish.ts

@ -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');
}; };
} }

18
src/hooks/useWechatBindPhone.ts

@ -19,7 +19,7 @@ export interface UseWechatBindPhoneReturn {
* 成功后写入登录态;返回会话交给页面决定是继续走身份选择还是直接落地。 * 成功后写入登录态;返回会话交给页面决定是继续走身份选择还是直接落地。
*/ */
export function useWechatBindPhone(): UseWechatBindPhoneReturn { export function useWechatBindPhone(): UseWechatBindPhoneReturn {
const { login } = useAuth(); const { applySession } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
@ -29,7 +29,19 @@ export function useWechatBindPhone(): UseWechatBindPhoneReturn {
setError(null); setError(null);
try { try {
const session = await bindWechatAndRegister(payload); const session = await bindWechatAndRegister(payload);
login(session.token, session.expiresAt); // 走唯一会话入口:双 Token 一起落库,请求层才能带上 Bearer
applySession({
accessToken: session.token,
refreshToken: session.refreshToken,
expiresAt: session.expiresAt,
// 后端记录过身份才写角色;未记录(undefined)时留给身份选择流程
backendRole:
session.role === 'enterprise'
? 'COMPANY'
: session.role === 'candidate'
? 'STUDENT'
: undefined,
});
return session; return session;
} catch (e) { } catch (e) {
const err = e instanceof Error ? e : new Error('绑定失败,请稍后重试'); const err = e instanceof Error ? e : new Error('绑定失败,请稍后重试');
@ -39,7 +51,7 @@ export function useWechatBindPhone(): UseWechatBindPhoneReturn {
setLoading(false); setLoading(false);
} }
}, },
[login], [applySession],
); );
return { bind, loading, error }; return { bind, loading, error };

17
src/hooks/useWechatLogin.ts

@ -63,7 +63,7 @@ export interface UseWechatLoginReturn {
const IDLE: WechatLoginState = { kind: 'idle' }; const IDLE: WechatLoginState = { kind: 'idle' };
export function useWechatLogin(): UseWechatLoginReturn { export function useWechatLogin(): UseWechatLoginReturn {
const { login, isTokenValid } = useAuth(); const { applySession, isTokenValid } = useAuth();
const [state, setState] = useState<WechatLoginState>(IDLE); const [state, setState] = useState<WechatLoginState>(IDLE);
const start = useCallback(async () => { const start = useCallback(async () => {
@ -88,7 +88,18 @@ export function useWechatLogin(): UseWechatLoginReturn {
if (!result.profile) { if (!result.profile) {
throw new Error('登录信息不完整,请重试'); throw new Error('登录信息不完整,请重试');
} }
login(result.token, result.expiresAt); applySession({
accessToken: result.token,
// 微信通道无后端契约,refreshToken 缺失时用空串占位(本通道整体 mock)
refreshToken: result.refreshToken ?? '',
expiresAt: result.expiresAt,
backendRole:
result.role === 'enterprise'
? 'COMPANY'
: result.role === 'candidate'
? 'STUDENT'
: undefined,
});
setState({ kind: 'success', profile: result.profile, role: result.role }); setState({ kind: 'success', profile: result.profile, role: result.role });
break; break;
} }
@ -111,7 +122,7 @@ export function useWechatLogin(): UseWechatLoginReturn {
message: e instanceof Error ? e.message : '微信登录失败,请稍后重试', message: e instanceof Error ? e.message : '微信登录失败,请稍后重试',
}); });
} }
}, [isTokenValid, login]); }, [isTokenValid, applySession]);
const cancelLogin = useCallback(() => { const cancelLogin = useCallback(() => {
setState(IDLE); setState(IDLE);

11
src/lib/token-store.ts

@ -51,7 +51,14 @@ export function getTokens(): TokenPair | null {
return memory; return memory;
} }
/** 写入并持久化;expiresAt 缺省时不覆盖已存值 */ /**
* 写入并持久化双 Token。
*
* `expiresAt` 为 undefined(后端没下发 expiresIn)时**清除**已存的过期时间,
* 而不是保留旧值:内存态此时是 undefined,持久化却留着上一次的绝对时间戳,
* 两者分叉 —— 下次启动 hydrate 会把这个已失效的旧时间戳当成新会话的到期时间。
* 宁可「不知道到期时间」(UI 侧另有兜底)也不要一个假的确定值。
*/
export async function setTokens( export async function setTokens(
accessToken: string, accessToken: string,
refreshToken: string, refreshToken: string,
@ -64,6 +71,8 @@ export async function setTokens(
]; ];
if (expiresAt !== undefined) { if (expiresAt !== undefined) {
writes.push(storageSet(KEY_EXP, String(expiresAt))); writes.push(storageSet(KEY_EXP, String(expiresAt)));
} else {
writes.push(storageRemove(KEY_EXP));
} }
await Promise.all(writes); await Promise.all(writes);
} }

17
src/types/auth.ts

@ -25,14 +25,31 @@ export type PasswordRuleError =
/** 重置 / 首次设置密码参数(忘记密码与首次设密码共用) */ /** 重置 / 首次设置密码参数(忘记密码与首次设密码共用) */
export interface PasswordResetPayload { export interface PasswordResetPayload {
phone: string; phone: string;
/** 短信验证码;发请求时映射为契约字段 `smsCode` */
code: string; code: string;
/** 新密码;发请求时映射为契约字段 `newPassword` */
password: string; password: string;
} }
/** 已登录态修改密码参数 */ /** 已登录态修改密码参数 */
export interface ChangePasswordPayload { export interface ChangePasswordPayload {
/** 原密码;发请求时映射为契约字段 `currentPassword` */
oldPassword: string; oldPassword: string;
newPassword: string; newPassword: string;
/** 确认新密码(前端二次输入,后端可忽略) */ /** 确认新密码(前端二次输入,后端可忽略) */
confirmPassword: string; confirmPassword: string;
} }
/**
* 密码操作(设置 / 重置 / 修改)的响应体,契约 `PasswordOperationResponse`。
*
* **里面没有 Token** —— 这三个接口只回答「密码现在配好了没有」「要不要重新登录」。
* 早期把它们当成登录接口(返回 `LoginResult`、页面拿 `result.token` 写登录态),
* 等于凭空造了一个后端从未颁发的会话。
*/
export interface PasswordOperationResult {
/** 密码是否已配置 */
passwordConfigured: boolean;
/** 后端要求重新登录后凭证才生效(改密后旧会话可能被撤销) */
reauthenticationRequired: boolean;
}

11
src/types/wechat-auth.ts

@ -58,6 +58,13 @@ export interface WechatLoginResult {
status: WechatAccountStatus; status: WechatAccountStatus;
/** 登录令牌(active 为正式 token;new 为临时凭证;deleted 无意义) */ /** 登录令牌(active 为正式 token;new 为临时凭证;deleted 无意义) */
token: string; token: string;
/**
* 刷新令牌,仅 status === 'active' 时返回。
* 微信登录当前无后端契约(整体 mock),真实接入时以厂商/后端返回为准;
* 这里先补齐字段,让微信登录与其他登录走**同一个会话写入入口**,
* 否则微信登录的用户请求全部带不出 Bearer。
*/
refreshToken?: string;
/** 微信开放平台唯一标识 */ /** 微信开放平台唯一标识 */
openid: string; openid: string;
/** 已注册用户资料,仅 status === 'active' 时返回 */ /** 已注册用户资料,仅 status === 'active' 时返回 */
@ -85,8 +92,10 @@ export interface WechatBindPhonePayload {
/** 绑定注册成功后返回的正式会话 */ /** 绑定注册成功后返回的正式会话 */
export interface WechatSession { export interface WechatSession {
/** 正式登录令牌 */ /** 正式访问令牌 */
token: string; token: string;
/** 刷新令牌(无契约,mock 阶段自造;真实接入时以后端返回为准) */
refreshToken: string;
/** 令牌过期时间戳(毫秒) */ /** 令牌过期时间戳(毫秒) */
expiresAt: number; expiresAt: number;
/** 用户资料 */ /** 用户资料 */

87
后端联调前端实现.md

@ -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:`,**勿擅自改名**(会导致老用户本地态失效)。

159
微信登录接入方案.md

@ -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 自定义客户端,三分支即可真机走通。

138
接口联调问题清单.md

@ -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()`"或"测试命中占位路径"**不算完成**。

BIN
职来后端接口联调说明.docx

Binary file not shown.

167
运营商一键登录接入方案.md

@ -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 自定义客户端,一键登录即可真机走通。

1921
项目开发状态.md

File diff suppressed because it is too large
Loading…
Cancel
Save