Files
teaching-feedback-assistant/utils/api.ts

478 lines
14 KiB
TypeScript

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<T>(path: string, method: HttpMethod = 'GET', data?: object, timeout = 10000): Promise<T> {
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<HealthStatus> {
const result = await request<RawHealthStatus>('/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<StudentProfile[]> {
return (await request<RawStudentProfile[]>('/api/v1/profiles')).map(mapProfile)
}
export async function createProfile(profile: StudentDraft): Promise<StudentProfile> {
return mapProfile(await request<RawStudentProfile>('/api/v1/profiles', 'POST', profileInput(profile)))
}
export async function deleteProfile(profileId: string): Promise<void> {
await request<unknown>(`/api/v1/profiles/${encodeURIComponent(profileId)}`, 'DELETE')
}
export async function getProfileDefaults(): Promise<StudentProfileDefaults> {
return mapDefaults(await request<RawProfileDefaults>('/api/v1/profile-defaults'))
}
export async function updateProfileDefaults(
defaults: StudentProfileDefaults
): Promise<StudentProfileDefaults> {
const result = await request<RawProfileDefaults>('/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<FeedbackRecord[]> {
const query = profileId ? `?profile_id=${encodeURIComponent(profileId)}` : ''
return (await request<RawFeedbackRecord[]>(`/api/v1/feedback-records${query}`)).map(mapFeedbackRecord)
}
export async function createFeedbackRecord(draft: FeedbackDraft): Promise<FeedbackRecord> {
const result = await request<RawFeedbackRecord>('/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<void> {
await request<unknown>(`/api/v1/feedback-records/${encodeURIComponent(recordId)}`, 'DELETE')
}
export async function getActiveFeedbackSession(profileId: string | null): Promise<FeedbackSession | null> {
const query = profileId ? `?profile_id=${encodeURIComponent(profileId)}` : ''
const result = await request<RawFeedbackSession | null>(`/api/v1/feedback-sessions/active${query}`)
return result ? mapFeedbackSession(result) : null
}
export async function ensureFeedbackSession(
profileId: string | null,
feedbackDate: string,
content: string
): Promise<FeedbackSession> {
const result = await request<RawFeedbackSession>('/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<FeedbackSession> {
const result = await request<RawFeedbackSession>(
`/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}`,
'PUT',
{ feedback_date: feedbackDate, content }
)
return mapFeedbackSession(result)
}
export async function createVoiceLesson(sessionId: string): Promise<VoiceLessonCreated> {
const result = await request<RawVoiceLessonCreated>(
`/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<VoiceAudioSegment> {
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<VoiceLessonFinished> {
const result = await request<RawVoiceLessonFinished>(
`/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<void> {
await request<unknown>(
`/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/mark-applied`,
'POST',
{ content }
)
}
export async function retryVoiceSegment(segmentId: string): Promise<VoiceAudioSegment> {
const result = await request<RawVoiceAudioSegment>(
`/api/v1/audio-segments/${encodeURIComponent(segmentId)}/retry`,
'POST',
undefined,
120000
)
return mapVoiceSegment(result)
}
export async function generateFeedbackFromVoice(
sessionId: string,
existingContent: string
): Promise<GeneratedFeedback> {
const result = await request<RawGeneratedFeedback>(
`/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}/generate`,
'POST',
{ existing_content: existingContent },
120000
)
return result
}