import { mkdir, readdir } from 'node:fs/promises'; import path from 'node:path'; import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json'; import type { TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher'; import { TeacherError } from './config-client'; export function teacherTopicId(id: string): string { if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id)) throw new TeacherError(400, 'teacher_topic_invalid', '老师话题标识无效。'); return id; } export class TeacherTopicStore { private readonly cache = new Map>(); constructor(readonly directory: string) {} async list(): Promise { await mkdir(this.directory, { recursive: true }); const names = await readdir(this.directory); const topics = await Promise.all( names .filter((name) => name.endsWith('.json') && name !== 'index.json') .map((name) => this.read(name.slice(0, -5))) ); let last: string | null = null; try { last = ( (await readJsonFile(path.join(this.directory, 'index.json'))) as { lastSelectedTopicId: string; } ).lastSelectedTopicId; } catch { /* index is disposable; topic files own history */ } const items = topics .map((topic) => ({ id: topic.id, title: topic.requests[0]?.text.slice(0, 32) || '新话题', updatedAt: topic.updatedAt, version: topic.version, })) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return { items, lastSelectedTopicId: items.some((item) => item.id === last) ? last : (items[0]?.id ?? null), }; } async read(id: string): Promise { teacherTopicId(id); let pending = this.cache.get(id); if (!pending) { pending = (async () => { let topic: TeacherTopic; try { topic = (await readJsonFile(path.join(this.directory, id + '.json'))) as TeacherTopic; } catch { throw new TeacherError(404, 'teacher_topic_not_found', '老师话题不存在或无法读取。'); } if (topic.id !== id || topic.schemaVersion !== 1 || !Array.isArray(topic.requests)) throw new TeacherError(409, 'teacher_topic_invalid', '老师历史无法读取。'); let recovered = false; for (const request of topic.requests) if (request.status === 'preparing' || request.status === 'running') { request.status = 'interrupted'; request.error = '应用已重启,本次回复中断。'; recovered = true; } if (recovered) { topic.revision++; await this.save(topic); } return topic; })(); this.cache.set(id, pending); pending.catch(() => this.cache.delete(id)); } return await pending; } async save(topic: TeacherTopic) { await mkdir(this.directory, { recursive: true }); const saved = { ...topic }; delete saved.unsaved; await atomicWriteJson(path.join(this.directory, teacherTopicId(topic.id) + '.json'), saved); topic.unsaved = false; this.cache.set(topic.id, Promise.resolve(topic)); } async select(id: string) { await atomicWriteJson(path.join(this.directory, 'index.json'), { lastSelectedTopicId: teacherTopicId(id), }); } }