import type { FeedbackDraft, FeedbackRecord, FeedbackSession, GeneratedFeedback, HealthStatus, StudentDraft, StudentProfile, StudentProfileDefaults, Term, VoiceAudioSegment, VoiceLesson, VoiceLessonCreated, VoiceLessonFinished, VoiceProcessingStatus } from './types' 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' export const DEVELOPMENT_USER_ID = '5a4da7f0-d70c-465f-bcab-124c504aa9f0' 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 } 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 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 ) { 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 ApiRequestError)) return '请求失败,请稍后重试' if (error.statusCode === 401) return '开发身份未通过后端校验' if (error.statusCode === 404) return '请求的数据不存在或无权访问' if (error.statusCode === 503 && error.message.includes('database')) return '后端尚未配置数据库连接' return error.message } function request(path: string, method: HttpMethod = 'GET', data?: object, timeout = 10000): Promise { return new Promise((resolve, reject) => { wx.request({ url: `${getApiBaseUrl()}${path}`, method, data, timeout, header: { 'Content-Type': 'application/json', 'X-User-Id': DEVELOPMENT_USER_ID }, 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)) }, 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, 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') return { status: result.status, databaseConfigured: result.database_configured, speechConfigured: result.speech_configured, speechAvailable: result.speech_available, speechProvider: result.speech_provider, summaryConfigured: result.summary_configured } } 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 function uploadVoiceSegment( lessonId: string, filePath: string, sequence: number, durationMs: number ): Promise { return new Promise((resolve, reject) => { wx.uploadFile({ url: `${getApiBaseUrl()}/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: { 'X-User-Id': DEVELOPMENT_USER_ID }, success(response) { let body: RawVoiceAudioSegment | ApiErrorBody try { body = JSON.parse(response.data) as RawVoiceAudioSegment | ApiErrorBody } catch { reject(new ApiRequestError('服务返回了无效的录音响应', response.statusCode)) 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)) }, fail(error) { reject(new ApiRequestError(`录音上传失败:${error.errMsg}`)) } }) }) } 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 generateFeedbackFromVoice( sessionId: string, existingContent: string ): Promise { const result = await request( `/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}/generate`, 'POST', { existing_content: existingContent }, 120000 ) return result }