478 lines
14 KiB
TypeScript
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
|
|
}
|