610 lines
17 KiB
TypeScript
610 lines
17 KiB
TypeScript
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<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),
|
|
createdAt: parseTimestamp(session.created_at),
|
|
updatedAt: parseTimestamp(session.updated_at)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|