import type { FeedbackDraft, FeedbackRecord, FeedbackSession, GeneratedFeedback, HealthStatus, StudentDraft, StudentProfile, StudentProfileDefaults, Term, VoiceAudioSegment, VoiceLesson, VoiceLessonCreated, VoiceLessonFinished, VoiceProcessingStatus } from './types' import { AuthenticationError, clearAuthSession, ensureAuthSession, getAccessToken } from './auth' import type { AuthSession } from './auth' const DEFAULT_API_BASE_URL = 'https://feedback.shay7sev.site' const LEGACY_DEFAULT_API_BASE_URL = 'http://127.0.0.1:8080' const API_BASE_URL_KEY = 'teaching-feedback-api-base-url-v1' interface ApiErrorBody { error?: string } interface RawStudentProfile { id: string name: string grade: string subject: string academic_year: number term: Term guardian_title: string start_time: string end_time: string created_at: string updated_at: string } interface RawProfileDefaults { grade: string subject: string lesson_duration_minutes: number updated_at: string } interface RawFeedbackRecord { id: string profile_id: string | null feedback_date: string content: string created_at: string updated_at: string } interface RawHealthStatus { status: string database_configured: boolean speech_configured: boolean speech_available: boolean speech_provider: string | null summary_configured: boolean wechat_auth_configured: boolean development_auth_enabled: boolean } interface RawVoiceAudioSegment { id: string sequence: number duration_ms: number status: 'processing' | 'ready' | 'failed' error_message: string | null } interface RawVoiceLesson { id: string sequence: number status: VoiceProcessingStatus duration_ms: number applied_directly: boolean included_in_generation: boolean transcript?: string segments: RawVoiceAudioSegment[] started_at: string ended_at: string | null } interface RawFeedbackSession { id: string profile_id: string | null feedback_date: string content: string status: 'active' | 'finalized' lesson_count: number total_duration_ms: number processing_segment_count: number failed_segment_count: number needs_generation: boolean lessons: RawVoiceLesson[] created_at: string updated_at: string } interface RawVoiceLessonCreated { id: string sequence: number status: VoiceProcessingStatus started_at: string } interface RawVoiceLessonFinished { id: string status: VoiceProcessingStatus duration_ms: number transcript: string can_apply_directly: boolean } interface RawGeneratedFeedback { content: string method: 'llm' | 'extractive' } type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' export class ApiRequestError extends Error { constructor( message: string, readonly statusCode?: number, readonly requestId?: string ) { super(message) this.name = 'ApiRequestError' } } function normalizeApiBaseUrl(value: string): string { return value.trim().replace(/\/+$/, '') } export function getApiBaseUrl(): string { const storedValue = wx.getStorageSync(API_BASE_URL_KEY) if (typeof storedValue !== 'string' || !storedValue.trim()) return DEFAULT_API_BASE_URL const normalized = normalizeApiBaseUrl(storedValue) if (normalized === LEGACY_DEFAULT_API_BASE_URL) { wx.setStorageSync(API_BASE_URL_KEY, DEFAULT_API_BASE_URL) return DEFAULT_API_BASE_URL } return normalized } export function setApiBaseUrl(value: string): string { const normalized = normalizeApiBaseUrl(value) if (!/^https?:\/\/[^\s]+$/i.test(normalized)) { throw new ApiRequestError('服务地址必须以 http:// 或 https:// 开头') } wx.setStorageSync(API_BASE_URL_KEY, normalized) return normalized } export function getApiErrorMessage(error: unknown): string { if (error instanceof AuthenticationError) return error.message if (!(error instanceof ApiRequestError)) return '请求失败,请稍后重试' if (error.statusCode === 401) return '登录状态已失效,请重试' if (error.statusCode === 404) return '请求的数据不存在或无权访问' if (error.statusCode === 503 && error.message.includes('database')) return '后端尚未配置数据库连接' return error.message } export function getApiRequestId(error: unknown): string { return error instanceof ApiRequestError ? error.requestId || '' : '' } export function copyApiRequestId(requestId: string): void { if (!requestId) return wx.setClipboardData({ data: requestId, success() { wx.showToast({ title: '请求编号已复制', icon: 'none' }) } }) } export function ensureAuthentication(): Promise { return ensureAuthSession(getApiBaseUrl()) } async function request( path: string, method: HttpMethod = 'GET', data?: object, timeout = 10000, authenticated = true ): Promise { const apiBaseUrl = getApiBaseUrl() if (!authenticated) return requestOnce(apiBaseUrl, path, method, data, timeout) let accessToken = await getAccessToken(apiBaseUrl) try { return await requestOnce(apiBaseUrl, path, method, data, timeout, accessToken) } catch (error) { if (!(error instanceof ApiRequestError) || error.statusCode !== 401) throw error clearAuthSession() accessToken = await getAccessToken(apiBaseUrl, true) return requestOnce(apiBaseUrl, path, method, data, timeout, accessToken) } } function requestOnce( apiBaseUrl: string, path: string, method: HttpMethod, data: object | undefined, timeout: number, accessToken?: string ): Promise { return new Promise((resolve, reject) => { wx.request({ url: `${apiBaseUrl}${path}`, method, data, timeout, header: { 'Content-Type': 'application/json', ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) }, success(response) { if (response.statusCode >= 200 && response.statusCode < 300) { resolve(response.data as T) return } const body = response.data as ApiErrorBody reject( new ApiRequestError( body?.error || `服务返回 HTTP ${response.statusCode}`, response.statusCode, responseRequestId(response.header) ) ) }, fail(error) { reject(new ApiRequestError(`无法连接后端:${error.errMsg}`)) } }) }) } function parseTimestamp(value: string): number { const timestamp = Date.parse(value) return Number.isNaN(timestamp) ? Date.now() : timestamp } function mapProfile(profile: RawStudentProfile): StudentProfile { return { id: profile.id, name: profile.name, grade: profile.grade, subject: profile.subject, academicYear: profile.academic_year, term: profile.term, guardianTitle: profile.guardian_title, startTime: profile.start_time, endTime: profile.end_time, createdAt: parseTimestamp(profile.created_at), updatedAt: parseTimestamp(profile.updated_at) } } function profileInput(profile: StudentDraft): object { return { name: profile.name, grade: profile.grade, subject: profile.subject, academic_year: profile.academicYear, term: profile.term, guardian_title: profile.guardianTitle, start_time: profile.startTime, end_time: profile.endTime } } function mapDefaults(defaults: RawProfileDefaults): StudentProfileDefaults { return { grade: defaults.grade, subject: defaults.subject, lessonDurationMinutes: defaults.lesson_duration_minutes } } function mapFeedbackRecord(record: RawFeedbackRecord): FeedbackRecord { return { id: record.id, profileId: record.profile_id, feedbackDate: record.feedback_date, content: record.content, createdAt: parseTimestamp(record.created_at), updatedAt: parseTimestamp(record.updated_at) } } function mapVoiceSegment(segment: RawVoiceAudioSegment): VoiceAudioSegment { return { id: segment.id, sequence: segment.sequence, durationMs: segment.duration_ms, status: segment.status, errorMessage: segment.error_message } } function mapVoiceLesson(lesson: RawVoiceLesson): VoiceLesson { return { id: lesson.id, sequence: lesson.sequence, status: lesson.status, durationMs: lesson.duration_ms, appliedDirectly: lesson.applied_directly, includedInGeneration: lesson.included_in_generation, transcript: lesson.transcript || '', segments: lesson.segments.map(mapVoiceSegment), startedAt: parseTimestamp(lesson.started_at), endedAt: lesson.ended_at ? parseTimestamp(lesson.ended_at) : null } } function mapFeedbackSession(session: RawFeedbackSession): FeedbackSession { return { id: session.id, profileId: session.profile_id, feedbackDate: session.feedback_date, content: session.content, status: session.status, lessonCount: session.lesson_count, totalDurationMs: session.total_duration_ms, processingSegmentCount: session.processing_segment_count, failedSegmentCount: session.failed_segment_count, needsGeneration: session.needs_generation, lessons: session.lessons.map(mapVoiceLesson), createdAt: parseTimestamp(session.created_at), updatedAt: parseTimestamp(session.updated_at) } } export async function getHealth(): Promise { const result = await request('/health', 'GET', undefined, 10000, false) return { status: result.status, databaseConfigured: result.database_configured, speechConfigured: result.speech_configured, speechAvailable: result.speech_available, speechProvider: result.speech_provider, summaryConfigured: result.summary_configured, wechatAuthConfigured: result.wechat_auth_configured, developmentAuthEnabled: result.development_auth_enabled } } export async function listProfiles(): Promise { return (await request('/api/v1/profiles')).map(mapProfile) } export async function createProfile(profile: StudentDraft): Promise { return mapProfile(await request('/api/v1/profiles', 'POST', profileInput(profile))) } export async function deleteProfile(profileId: string): Promise { await request(`/api/v1/profiles/${encodeURIComponent(profileId)}`, 'DELETE') } export async function getProfileDefaults(): Promise { return mapDefaults(await request('/api/v1/profile-defaults')) } export async function updateProfileDefaults( defaults: StudentProfileDefaults ): Promise { const result = await request('/api/v1/profile-defaults', 'PUT', { grade: defaults.grade, subject: defaults.subject, lesson_duration_minutes: defaults.lessonDurationMinutes }) return mapDefaults(result) } export async function listFeedbackRecords(profileId?: string): Promise { const query = profileId ? `?profile_id=${encodeURIComponent(profileId)}` : '' return (await request(`/api/v1/feedback-records${query}`)).map(mapFeedbackRecord) } export async function createFeedbackRecord(draft: FeedbackDraft): Promise { const result = await request('/api/v1/feedback-records', 'POST', { profile_id: draft.profileId, feedback_date: draft.feedbackDate, content: draft.content, feedback_session_id: draft.feedbackSessionId || null }) return mapFeedbackRecord(result) } export async function deleteFeedbackRecord(recordId: string): Promise { await request(`/api/v1/feedback-records/${encodeURIComponent(recordId)}`, 'DELETE') } export async function getActiveFeedbackSession(profileId: string | null): Promise { const query = profileId ? `?profile_id=${encodeURIComponent(profileId)}` : '' const result = await request(`/api/v1/feedback-sessions/active${query}`) return result ? mapFeedbackSession(result) : null } export async function ensureFeedbackSession( profileId: string | null, feedbackDate: string, content: string ): Promise { const result = await request('/api/v1/feedback-sessions', 'POST', { profile_id: profileId, feedback_date: feedbackDate, content }) return mapFeedbackSession(result) } export async function updateFeedbackSession( sessionId: string, feedbackDate: string, content: string ): Promise { const result = await request( `/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}`, 'PUT', { feedback_date: feedbackDate, content } ) return mapFeedbackSession(result) } export async function createVoiceLesson(sessionId: string): Promise { const result = await request( `/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}/lessons`, 'POST' ) return { id: result.id, sequence: result.sequence, status: result.status, startedAt: parseTimestamp(result.started_at) } } export async function uploadVoiceSegment( lessonId: string, filePath: string, sequence: number, durationMs: number ): Promise { const apiBaseUrl = getApiBaseUrl() let accessToken = await getAccessToken(apiBaseUrl) try { return await uploadVoiceSegmentOnce( apiBaseUrl, accessToken, lessonId, filePath, sequence, durationMs ) } catch (error) { if (!(error instanceof ApiRequestError) || error.statusCode !== 401) throw error clearAuthSession() accessToken = await getAccessToken(apiBaseUrl, true) return uploadVoiceSegmentOnce( apiBaseUrl, accessToken, lessonId, filePath, sequence, durationMs ) } } function uploadVoiceSegmentOnce( apiBaseUrl: string, accessToken: string, lessonId: string, filePath: string, sequence: number, durationMs: number ): Promise { return new Promise((resolve, reject) => { wx.uploadFile({ url: `${apiBaseUrl}/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/segments`, filePath, name: 'audio', formData: { sequence: String(sequence), duration_ms: String(Math.max(1, Math.round(durationMs))), audio_format: 'mp3' }, timeout: 120000, header: { Authorization: `Bearer ${accessToken}` }, success(response) { let body: RawVoiceAudioSegment | ApiErrorBody try { body = JSON.parse(response.data) as RawVoiceAudioSegment | ApiErrorBody } catch { reject( new ApiRequestError( '服务返回了无效的录音响应', response.statusCode, responseRequestId(uploadResponseHeaders(response)) ) ) return } if (response.statusCode >= 200 && response.statusCode < 300) { resolve(mapVoiceSegment(body as RawVoiceAudioSegment)) return } reject( new ApiRequestError( (body as ApiErrorBody).error || `服务返回 HTTP ${response.statusCode}`, response.statusCode, responseRequestId(uploadResponseHeaders(response)) ) ) }, fail(error) { reject(new ApiRequestError(`录音上传失败:${error.errMsg}`)) } }) }) } function responseRequestId(headers: Record | undefined): string | undefined { for (const [name, value] of Object.entries(headers || {})) { if (name.toLowerCase() !== 'x-request-id' || typeof value !== 'string') continue const requestId = value.trim() if (requestId) return requestId } return undefined } function uploadResponseHeaders(response: unknown): Record | undefined { const headers = (response as { header?: unknown }).header return headers && typeof headers === 'object' ? (headers as Record) : undefined } export async function finishVoiceLesson(lessonId: string): Promise { const result = await request( `/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/finish`, 'POST', undefined, 120000 ) return { id: result.id, status: result.status, durationMs: result.duration_ms, transcript: result.transcript, canApplyDirectly: result.can_apply_directly } } export async function markVoiceLessonApplied(lessonId: string, content: string): Promise { await request( `/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/mark-applied`, 'POST', { content } ) } export async function retryVoiceSegment(segmentId: string): Promise { const result = await request( `/api/v1/audio-segments/${encodeURIComponent(segmentId)}/retry`, 'POST', undefined, 120000 ) return mapVoiceSegment(result) } export async function discardVoiceSegment(segmentId: string): Promise { await request( `/api/v1/audio-segments/${encodeURIComponent(segmentId)}`, 'DELETE' ) } export async function generateFeedbackFromVoice( sessionId: string, existingContent: string ): Promise { const result = await request( `/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}/generate`, 'POST', { existing_content: existingContent }, 120000 ) return result }