From 5f47b8e38be6f12860988d6c54b395a8322cca0b Mon Sep 17 00:00:00 2001 From: doublex001 <16157008+doublex001@user.noreply.gitee.com> Date: Wed, 30 Sep 2026 10:21:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=8E=A5=E5=85=A5=E5=9B=9B?= =?UTF-8?q?=E7=BB=B4=E8=83=BD=E5=8A=9B=E6=B5=8B=E8=AF=84=EF=BC=88/abilitie?= =?UTF-8?q?s=EF=BC=89=E5=B9=B6=E6=A1=A5=E6=8E=A5=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 fetchAbilityTypes / createAbilityAttempt / fetchAbilityAttempt / saveAbilityAnswers / submitAbilityAttempt / fetchAbilityResult / fetchMyAbilities - fetchAbilityAssessment 补真实分支:取最近一份 COMPLETED 结果并映射 - 实网实测契约(文档未标注): 题目 id 是 attemptQuestionId、答案字段是 selectedOptionId(写 optionId 报 9805); 答卷详情不带 version,保存与提交均无需乐观锁;未答完提交报 9806 - 四维 code 为 LOGIC/COMMUNICATION/EXECUTION/LEARNING,与 mock 阶段的 「专业能力/项目经验/技能广度」不是同一套口径,真实态以后端为准(产品口径待定夺) - 结果有效期 validUntil/validityStatus 由后端判定,前端不自行作废 新增 src/__tests__/ability-attempt.test.ts(5 用例) tsc 0 错误;jest 530 全过;eslint 0 error --- src/__tests__/ability-attempt.test.ts | 170 ++++++++++++++++++++++++++ src/api/api.ts | 136 ++++++++++++++++++++- src/types/growth.ts | 60 +++++++++ src/types/index.ts | 4 + 4 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/ability-attempt.test.ts diff --git a/src/__tests__/ability-attempt.test.ts b/src/__tests__/ability-attempt.test.ts new file mode 100644 index 0000000..26a7835 --- /dev/null +++ b/src/__tests__/ability-attempt.test.ts @@ -0,0 +1,170 @@ +/** + * 四维能力测评(/abilities)真实分支测试 + * + * 与霍兰德答卷的两个关键差异(实网踩坑点): + * - 题目 id 是 attemptQuestionId、答案字段是 selectedOptionId,写错报 9805 + * - 答卷详情不带 version,保存/提交都不需要乐观锁 + */ +jest.mock('expo-constants', () => ({ expoConfig: { extra: {} } })); + +jest.mock('../lib/token-store', () => { + let mem: { accessToken: string; refreshToken: string; expiresAt?: number } | null = null; + return { + getAccessToken: jest.fn(() => mem?.accessToken ?? null), + getTokens: jest.fn(() => mem), + setTokens: jest.fn((a: string, r: string) => { + mem = { accessToken: a, refreshToken: r }; + }), + clearTokens: jest.fn(() => { + mem = null; + }), + hydrateTokens: jest.fn(async () => undefined), + }; +}); + +jest.mock('../lib/storage', () => { + const m = new Map(); + return { + storageGet: jest.fn(async (k: string) => m.get(k) ?? null), + storageSet: jest.fn(async (k: string, v: string) => { + m.set(k, v); + }), + storageRemove: jest.fn(async () => undefined), + }; +}); + +import { setMockMode, API_BASE_URL } from '../api/http'; +import { + fetchAbilityAssessment, + fetchAbilityTypes, + createAbilityAttempt, + saveAbilityAnswers, + submitAbilityAttempt, +} from '../api/api'; + +type Responder = (url: string) => { status: number; body: unknown }; +let responder: Responder | null = null; +let calls: Array<{ url: string; init: RequestInit }> = []; + +function okData(data: unknown) { + return { status: 200, body: { code: 200, message: 'ok', data, requestId: 'r', timestamp: 't' } }; +} + +const ABILITY_RESULT = { + attemptId: 1, + typeCode: 'WORKPLACE_ABILITY', + status: 'COMPLETED', + validityStatus: 'VALID', + overallScore: 44.79, + dimensionScores: [ + { code: 'LOGIC', score: 37.5 }, + { code: 'COMMUNICATION', score: 58.33 }, + ], + improvementAreas: [ + { + dimension: 'LEARNING', + currentScore: 12.5, + summary: '把反馈变成具体行动并通过再次验证确认改进。', + actions: ['把模糊反馈转化为具体例子'], + }, + ], +}; + +beforeEach(() => { + calls = []; + responder = null; + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async (url: string, init: RequestInit) => { + calls.push({ url, init }); + const spec = responder ? responder(url) : okData(null); + return { + status: spec.status, + ok: spec.status >= 200 && spec.status < 300, + headers: { get: () => 'r' }, + text: async () => JSON.stringify(spec.body), + } as unknown as Response; + }); +}); + +describe('能力测评真实态', () => { + beforeEach(() => setMockMode(false)); + + it('已完成时映射综合分/等级/四维与提升建议', async () => { + responder = (url) => { + if (url.includes('/abilities/me')) + return okData({ + items: [{ attemptId: 1, typeCode: 'WORKPLACE_ABILITY', status: 'COMPLETED' }], + total: 1, + }); + if (url.includes('/result')) return okData(ABILITY_RESULT); + return okData(null); + }; + const data = await fetchAbilityAssessment(); + expect(data.overallScore).toBe(44.79); + expect(data.level).toBe('medium'); // <75 + expect(data.dimensions).toEqual([ + { label: '逻辑分析', value: 37.5 }, + { label: '沟通协作', value: 58.33 }, + ]); + expect(data.improvements[0].title).toBe('学习成长'); + // 后端不下发百分位与趋势:留空,不拿 mock 顶替 + expect(data.percentile).toBeUndefined(); + expect(data.trends).toEqual([]); + }); + + it('未完成时抛错,不静默返回 0 分', async () => { + responder = () => okData({ items: [], total: 0 }); + await expect(fetchAbilityAssessment()).rejects.toThrow('暂无能力测评结果'); + }); + + it('创建/答题/提交打真实路径,答案字段为 selectedOptionId', async () => { + responder = (url) => { + if (url.includes('/abilities/attempts') && !url.includes('/answers')) + return okData({ + attemptId: 7, + status: 'DRAFT', + totalQuestions: 24, + answeredCount: 0, + questions: [ + { + attemptQuestionId: 1, + position: 1, + itemType: 'SITUATIONAL_JUDGMENT', + stem: '估算工期', + options: [{ optionId: 'A', text: '给大致天数' }], + }, + ], + }); + return okData(null); + }; + await createAbilityAttempt(); + expect(calls[0].url).toBe(`${API_BASE_URL}/abilities/attempts`); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ typeCode: 'WORKPLACE_ABILITY' }); + + await saveAbilityAnswers(7, [{ attemptQuestionId: 1, selectedOptionId: 'A' }]); + const patch = calls.find((c) => c.init.method === 'PATCH'); + expect(patch?.url).toBe(`${API_BASE_URL}/abilities/attempts/7/answers`); + expect(JSON.parse(String(patch?.init.body))).toEqual({ + answers: [{ attemptQuestionId: 1, selectedOptionId: 'A' }], + }); + + await submitAbilityAttempt(7); + const post = calls.find((c) => c.url.endsWith('/submit')); + expect(post?.url).toBe(`${API_BASE_URL}/abilities/attempts/7/submit`); + }); + + it('类型列表打 /abilities/types', async () => { + responder = () => okData([{ typeCode: 'WORKPLACE_ABILITY', displayName: '四维能力测评' }]); + await fetchAbilityTypes(); + expect(calls[0].url).toBe(`${API_BASE_URL}/abilities/types`); + }); +}); + +describe('能力测评 mock 态', () => { + beforeEach(() => setMockMode(true)); + + it('mock 模式不发请求且保留原四维文案', async () => { + const data = await fetchAbilityAssessment(); + expect(calls.length).toBe(0); + expect(data.dimensions[0].label).toBe('专业能力'); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index 830e194..0f426c1 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -16,6 +16,8 @@ import Constants from 'expo-constants'; import type { AbilityAssessment, + BackendAbilityAttempt, + BackendAbilityResult, BackendAssessmentAttempt, BackendAssessmentQuestion, BackendAssessmentResult, @@ -1812,14 +1814,136 @@ function mapResultToHollandResult( * @returns Promise 能力测评结果 */ export async function fetchAbilityAssessment(): Promise { - // 后端未提供 OpenAPI 契约:保持 mock(翻真实开关也不触网),待后端就绪再接真实分支 - // TODO: const res = await fetch(`${API_BASE_URL}/api/growth/ability`, { method: 'GET' }); return res.json(); - return mockRequest({ - ...MOCK_ABILITY, - level: abilityScoreToLevel(MOCK_ABILITY.overallScore), + if (isMockMode()) { + return mockRequest({ + ...MOCK_ABILITY, + level: abilityScoreToLevel(MOCK_ABILITY.overallScore), + }); + } + // 真实后端:取最近一份已完成的能力测评结果(未完成时抛错,页面走空态)。 + // 结果带有效期(validUntil / validityStatus),过期后需重测 —— 由后端判定,前端不自行作废。 + const attemptId = await findCompletedAbilityAttemptId(); + if (!attemptId) throw new Error('暂无能力测评结果:请先完成测评'); + const raw = await fetchAbilityResult(attemptId); + return mapAbilityResult(raw); +} + +/* ===================== 能力测评(真实后端 /abilities) ===================== */ + +/** 四维能力测评在后端注册的 typeCode(实网 /abilities/types 采样) */ +const ABILITY_TYPE_CODE = 'WORKPLACE_ABILITY'; + +/** + * 后端四维 code → 中文名。 + * + * 注意:后端四维是「职场通用能力」(LOGIC/COMMUNICATION/EXECUTION/LEARNING), + * 与 mock 阶段的「专业能力/项目经验/技能广度」不是同一套口径。真实态以后端口径为准, + * 产品若要求沿用旧文案,需后端改维度定义而不是前端改映射。 + */ +const ABILITY_DIMENSION_LABEL: Record = { + LOGIC: '逻辑分析', + COMMUNICATION: '沟通协作', + EXECUTION: '执行落地', + LEARNING: '学习成长', +}; + +/** 能力测评类型列表(GET /abilities/types) */ +export async function fetchAbilityTypes(): Promise { + return request('/abilities/types', { method: 'GET', auth: true }); +} + +/** 创建能力测评答卷(POST /abilities/attempts) */ +export async function createAbilityAttempt( + typeCode: string = ABILITY_TYPE_CODE, +): Promise { + return request('/abilities/attempts', { + method: 'POST', + auth: true, + body: { typeCode }, }); - // 真实路径 +} + +/** 答卷详情(含情境判断题与选项) */ +export async function fetchAbilityAttempt(attemptId: number): Promise { + return request(`/abilities/attempts/${attemptId}`, { + method: 'GET', + auth: true, + }); +} + +/** + * 保存能力测评答案。 + * 答案字段是 `selectedOptionId`(不是 optionId / value), + * 题目 id 字段是 `attemptQuestionId` —— 写错任一字段名后端报 9805。 + */ +export async function saveAbilityAnswers( + attemptId: number, + answers: { attemptQuestionId: number; selectedOptionId: string }[], +): Promise { + return request(`/abilities/attempts/${attemptId}/answers`, { + method: 'PATCH', + auth: true, + body: { answers }, + }); +} + +/** 提交能力测评(未答完报 9806) */ +export async function submitAbilityAttempt(attemptId: number): Promise { + return request(`/abilities/attempts/${attemptId}/submit`, { + method: 'POST', + auth: true, + }); +} + +/** 能力测评结果(GET /abilities/attempts/{id}/result) */ +export async function fetchAbilityResult(attemptId: number): Promise { + return request(`/abilities/attempts/${attemptId}/result`, { + method: 'GET', + auth: true, + }); +} + +/** 我的能力测评列表(GET /abilities/me) */ +export async function fetchMyAbilities(): Promise { + const res = await request<{ items: BackendAssessmentSummary[]; total: number }>('/abilities/me', { + method: 'GET', + auth: true, + query: { page: 0, size: 20 }, + }); + return res.items ?? []; +} +/** 取一份已完成的能力测评 attemptId */ +async function findCompletedAbilityAttemptId(): Promise { + const mine = await fetchMyAbilities(); + const done = mine.find( + (item) => item.typeCode === ABILITY_TYPE_CODE && item.status === 'COMPLETED', + ); + return done ? done.attemptId : null; +} + +/** 后端能力测评结果 → 前端 AbilityAssessment */ +function mapAbilityResult(raw: BackendAbilityResult): AbilityAssessment { + const overallScore = raw.overallScore ?? 0; + return { + overallScore, + level: abilityScoreToLevel(overallScore), + dimensions: (raw.dimensionScores ?? []).map((item) => ({ + label: ABILITY_DIMENSION_LABEL[item.code] ?? item.code, + value: item.score ?? 0, + })), + // 后端不下发百分位与成长趋势:留空,不拿 mock 的「超过 78% 同岗位求职者」顶替 + percentile: undefined, + trends: [], + // 后端 improvementAreas 是「维度 + 小结 + 行动项」,前端是「标题 + 描述 + 图标」的卡片, + // 标题用维度名、描述用后端小结(行动项在详情页展开,卡片层不铺开) + improvements: (raw.improvementAreas ?? []).map((area, index) => ({ + id: `imp-${index}`, + title: ABILITY_DIMENSION_LABEL[area.dimension] ?? area.dimension, + description: area.summary ?? '', + icon: 'trending-up-outline', + })), + }; } /** diff --git a/src/types/growth.ts b/src/types/growth.ts index 8653f88..7f030ab 100644 --- a/src/types/growth.ts +++ b/src/types/growth.ts @@ -319,3 +319,63 @@ export interface BackendAssessmentSummary { completionRate?: number; createdAt?: string; } + +/* ===================== 能力测评后端 DTO(2026-09-30 实网采样) ===================== + * 与霍兰德答卷同构但细节不同,分开建模: + * - 题目是**情境判断选择题**(带 options),不是 1-5 量表 + * - 题目 id 字段是 attemptQuestionId,答案字段是 selectedOptionId + * - 答卷详情不带 version,保存/提交都不需要乐观锁 + */ + +export interface BackendAbilityOption { + optionId: string; + text: string; +} + +export interface BackendAbilityQuestion { + attemptQuestionId: number; + position: number; + /** 题型,如 SITUATIONAL_JUDGMENT */ + itemType?: string; + stem: string; + options: BackendAbilityOption[]; + /** 已选选项(回显时用) */ + selectedOptionId?: string; +} + +export interface BackendAbilityAttempt { + attemptId: number; + typeCode?: string; + status: string; + totalQuestions: number; + answeredCount: number; + completionRate?: number; + questions: BackendAbilityQuestion[]; +} + +export interface BackendAbilityResult { + attemptId: number; + typeCode: string; + status: string; + /** 结果有效期状态:VALID / EXPIRED */ + validityStatus?: string; + validUntil?: string; + overallScore?: number; + /** 四维得分,code 为 LOGIC / COMMUNICATION / EXECUTION / LEARNING */ + dimensionScores?: { code: string; score: number; answeredCount?: number }[]; + weaknesses?: { code: string; score: number }[]; + improvementAreas?: { + dimension: string; + currentScore?: number; + summary?: string; + actions?: string[]; + }[]; + radar?: { code: string; value: number }[]; + questionFeedback?: { + attemptQuestionId: number; + selectedOptionId?: string; + awardedScore?: number; + maxScore?: number; + explanation?: string; + }[]; +} diff --git a/src/types/index.ts b/src/types/index.ts index c438782..3d647b5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,6 +28,10 @@ export type { AbilityTrendPoint, AITool, Article, + BackendAbilityAttempt, + BackendAbilityOption, + BackendAbilityQuestion, + BackendAbilityResult, BackendAssessmentAttempt, BackendAssessmentQuestion, BackendAssessmentResult,