Files
teaching-feedback-assistant/utils/api.ts
2026-07-22 20:08:21 +08:00

690 lines
20 KiB
TypeScript

import type {
FeedbackDraft,
FeedbackRecord,
FeedbackSession,
FeedbackSummaryRun,
FeedbackSummaryStatus,
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[]
latest_summary_run?: RawFeedbackSummaryRun | null
created_at: string
updated_at: string
}
interface RawFeedbackSummaryRun {
id: string
feedback_session_id: string
lesson_ids: string[]
status: FeedbackSummaryStatus
content: string | null
method: string | null
model: string | null
prompt_version: string
character_count: number | null
error_message: string | null
attempts: number
created_at: string
completed_at: string | null
applied_at: string | null
}
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' | 'llm_hierarchical' | '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 '后端尚未配置数据库连接'
if (error.statusCode && error.statusCode >= 500) return '操作未完成,请稍后重试'
if (error.message.toLowerCase() === 'internal server error') {
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<AuthSession> {
return ensureAuthSession(getApiBaseUrl())
}
async function request<T>(
path: string,
method: HttpMethod = 'GET',
data?: object,
timeout = 10000,
authenticated = true
): Promise<T> {
const apiBaseUrl = getApiBaseUrl()
if (!authenticated) return requestOnce<T>(apiBaseUrl, path, method, data, timeout)
let accessToken = await getAccessToken(apiBaseUrl)
try {
return await requestOnce<T>(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<T>(apiBaseUrl, path, method, data, timeout, accessToken)
}
}
function requestOnce<T>(
apiBaseUrl: string,
path: string,
method: HttpMethod,
data: object | undefined,
timeout: number,
accessToken?: string
): Promise<T> {
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),
latestSummaryRun: session.latest_summary_run
? mapFeedbackSummaryRun(session.latest_summary_run)
: null,
createdAt: parseTimestamp(session.created_at),
updatedAt: parseTimestamp(session.updated_at)
}
}
function mapFeedbackSummaryRun(run: RawFeedbackSummaryRun): FeedbackSummaryRun {
return {
id: run.id,
feedbackSessionId: run.feedback_session_id,
lessonIds: run.lesson_ids,
status: run.status,
content: run.content,
method: run.method,
model: run.model,
promptVersion: run.prompt_version,
characterCount: run.character_count,
errorMessage: run.error_message,
attempts: run.attempts,
createdAt: parseTimestamp(run.created_at),
completedAt: run.completed_at ? parseTimestamp(run.completed_at) : null,
appliedAt: run.applied_at ? parseTimestamp(run.applied_at) : null
}
}
export async function getHealth(): Promise<HealthStatus> {
const result = await request<RawHealthStatus>('/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<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 async function uploadVoiceSegment(
lessonId: string,
filePath: string,
sequence: number,
durationMs: number
): Promise<VoiceAudioSegment> {
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<VoiceAudioSegment> {
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<string, unknown> | 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<string, unknown> | undefined {
const headers = (response as { header?: unknown }).header
return headers && typeof headers === 'object' ? (headers as Record<string, unknown>) : undefined
}
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 discardVoiceSegment(segmentId: string): Promise<void> {
await request<unknown>(
`/api/v1/audio-segments/${encodeURIComponent(segmentId)}`,
'DELETE'
)
}
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
}
export async function createFeedbackSummaryRun(
sessionId: string,
lessonIds: string[],
existingContent: string,
idempotencyKey: string
): Promise<FeedbackSummaryRun> {
const result = await request<RawFeedbackSummaryRun>(
`/api/v1/feedback-sessions/${encodeURIComponent(sessionId)}/summary-runs`,
'POST',
{
lesson_ids: lessonIds,
existing_content: existingContent,
idempotency_key: idempotencyKey
},
30000
)
return mapFeedbackSummaryRun(result)
}
export async function getFeedbackSummaryRun(runId: string): Promise<FeedbackSummaryRun> {
const result = await request<RawFeedbackSummaryRun>(
`/api/v1/feedback-summary-runs/${encodeURIComponent(runId)}`
)
return mapFeedbackSummaryRun(result)
}
export async function applyFeedbackSummaryRun(runId: string): Promise<FeedbackSession> {
const result = await request<RawFeedbackSession>(
`/api/v1/feedback-summary-runs/${encodeURIComponent(runId)}/apply`,
'POST'
)
return mapFeedbackSession(result)
}