Initial commit
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { Brackets, type Repository } from 'typeorm'
|
||||
|
||||
import {
|
||||
StudentProfileEntitySchema,
|
||||
type StudentProfileEntity
|
||||
} from '../entities/StudentProfileEntity'
|
||||
import { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity'
|
||||
import type { ChildProfile, ClassProfile, ListStudentProfilesInput } from '../types/student'
|
||||
import { getAppDataSource } from './databaseService'
|
||||
import { getChatCompletionsUrl, logLargeModelRequest } from './modelService'
|
||||
import { loadSettings } from './settingsService'
|
||||
|
||||
function parseJsonList(value: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyList(items: string[]): string {
|
||||
return JSON.stringify(items)
|
||||
}
|
||||
|
||||
function normalizeClassType(type: string): 'cheap' | 'noble' {
|
||||
return type === 'noble' ? 'noble' : 'cheap'
|
||||
}
|
||||
|
||||
function mapClassEntity(entity: ClassEntity): ClassProfile {
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
type: normalizeClassType(entity.type),
|
||||
familyPhoto: entity.familyPhoto ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mapChildEntity(entity: StudentProfileEntity): ChildProfile {
|
||||
return {
|
||||
id: entity.id,
|
||||
classId: entity.classId,
|
||||
className: entity.className,
|
||||
name: entity.name,
|
||||
englishName: entity.englishName,
|
||||
gender: entity.gender,
|
||||
birthday: entity.birthday,
|
||||
zodiac: entity.zodiac,
|
||||
friends: parseJsonList(entity.friends),
|
||||
hobbies: parseJsonList(entity.hobbies),
|
||||
favoriteGames: parseJsonList(entity.favoriteGames),
|
||||
favoriteFoods: parseJsonList(entity.favoriteFoods),
|
||||
traits: entity.traits,
|
||||
comment: entity.comment ?? '',
|
||||
commentGeneratedAt: entity.commentGeneratedAt ?? '',
|
||||
importedAt: entity.importedAt
|
||||
}
|
||||
}
|
||||
|
||||
function mapClassProfile(profile: ClassProfile): ClassEntity {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
type: profile.type,
|
||||
familyPhoto: profile.familyPhoto ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function mapChildProfile(profile: ChildProfile, classItem?: ClassProfile): StudentProfileEntity {
|
||||
return {
|
||||
id: profile.id,
|
||||
classId: classItem?.id ?? profile.classId,
|
||||
className: classItem?.name ?? profile.className,
|
||||
name: profile.name,
|
||||
englishName: profile.englishName,
|
||||
gender: profile.gender,
|
||||
birthday: profile.birthday,
|
||||
zodiac: profile.zodiac,
|
||||
friends: stringifyList(profile.friends),
|
||||
hobbies: stringifyList(profile.hobbies),
|
||||
favoriteGames: stringifyList(profile.favoriteGames),
|
||||
favoriteFoods: stringifyList(profile.favoriteFoods),
|
||||
traits: profile.traits,
|
||||
comment: profile.comment ?? '',
|
||||
commentGeneratedAt: profile.commentGeneratedAt ?? '',
|
||||
importedAt: profile.importedAt
|
||||
}
|
||||
}
|
||||
|
||||
function buildStudentCommentPrompt(profile: ChildProfile): string {
|
||||
return [
|
||||
'请为这名幼儿生成一段成长报告评语。',
|
||||
'要求:语气温暖、具体、积极,适合幼儿园成长报告;不要编造资料里没有的姓名、日期或家庭信息;长度控制在 120 到 180 字。',
|
||||
'',
|
||||
`姓名:${profile.name || '未填写'}`,
|
||||
`英文名:${profile.englishName || '未填写'}`,
|
||||
`班级:${profile.className || '未分班'}`,
|
||||
`性别:${profile.gender || '未填写'}`,
|
||||
`生日:${profile.birthday || '未填写'}`,
|
||||
`属相:${profile.zodiac || '未填写'}`,
|
||||
`好朋友:${profile.friends.length > 0 ? profile.friends.join('、') : '未填写'}`,
|
||||
`爱好:${profile.hobbies.length > 0 ? profile.hobbies.join('、') : '未填写'}`,
|
||||
`喜欢的游戏:${profile.favoriteGames.length > 0 ? profile.favoriteGames.join('、') : '未填写'}`,
|
||||
`喜欢吃的食物:${profile.favoriteFoods.length > 0 ? profile.favoriteFoods.join('、') : '未填写'}`,
|
||||
`表现特征:${profile.traits || '未填写'}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function getRepositories(): Promise<{
|
||||
classRepository: Repository<ClassEntity>
|
||||
childRepository: Repository<StudentProfileEntity>
|
||||
}> {
|
||||
const source = await getAppDataSource()
|
||||
|
||||
return {
|
||||
classRepository: source.getRepository<ClassEntity>(ClassEntitySchema),
|
||||
childRepository: source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadStudentData(): Promise<{
|
||||
classes: ClassProfile[]
|
||||
profiles: ChildProfile[]
|
||||
}> {
|
||||
const { classRepository, childRepository } = await getRepositories()
|
||||
const classes = await classRepository.find({
|
||||
order: {
|
||||
id: 'ASC'
|
||||
}
|
||||
})
|
||||
const profiles = await childRepository.find({
|
||||
order: {
|
||||
id: 'ASC'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
classes: classes.map(mapClassEntity),
|
||||
profiles: profiles.map(mapChildEntity)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listStudentProfiles(input: ListStudentProfilesInput): Promise<{
|
||||
profiles: ChildProfile[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}> {
|
||||
const pageSize = Math.min(Math.max(Number(input.pageSize) || 10, 1), 100)
|
||||
const page = Math.max(Number(input.page) || 1, 1)
|
||||
const keyword = input.query?.trim()
|
||||
const source = await getAppDataSource()
|
||||
const queryBuilder = source
|
||||
.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
.createQueryBuilder('profile')
|
||||
.orderBy('profile.className', 'ASC')
|
||||
.addOrderBy('profile.name', 'ASC')
|
||||
.addOrderBy('profile.id', 'ASC')
|
||||
|
||||
if (input.classId && input.classId !== 'all') {
|
||||
queryBuilder.andWhere('profile.classId = :classId', { classId: input.classId })
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
queryBuilder.andWhere(
|
||||
new Brackets((builder) => {
|
||||
builder
|
||||
.where('profile.className LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
.orWhere('profile.name LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
.orWhere('profile.englishName LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
.orWhere('profile.gender LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
.orWhere('profile.birthday LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
.orWhere('profile.zodiac LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
.orWhere('profile.friends LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
.orWhere('profile.hobbies LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
.orWhere('profile.favoriteGames LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
.orWhere('profile.favoriteFoods LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
.orWhere('profile.traits LIKE :keyword', { keyword: `%${keyword}%` })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const [profiles, total] = await queryBuilder
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount()
|
||||
|
||||
return {
|
||||
profiles: profiles.map(mapChildEntity),
|
||||
total,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateStudentProfile(profile: ChildProfile): Promise<ChildProfile> {
|
||||
const source = await getAppDataSource()
|
||||
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
const existingProfile = await repository.findOneBy({ id: profile.id })
|
||||
|
||||
if (!existingProfile) {
|
||||
throw new Error('学生信息不存在')
|
||||
}
|
||||
|
||||
const nextProfile: ChildProfile = {
|
||||
...profile,
|
||||
comment: profile.comment ?? '',
|
||||
commentGeneratedAt: profile.commentGeneratedAt ?? existingProfile.commentGeneratedAt ?? ''
|
||||
}
|
||||
|
||||
await repository.save(mapChildProfile(nextProfile))
|
||||
return nextProfile
|
||||
}
|
||||
|
||||
export async function generateStudentComment(profileId: string): Promise<ChildProfile> {
|
||||
const source = await getAppDataSource()
|
||||
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
const entity = await repository.findOneBy({ id: profileId })
|
||||
|
||||
if (!entity) {
|
||||
throw new Error('学生信息不存在')
|
||||
}
|
||||
|
||||
const profile = mapChildEntity(entity)
|
||||
const settings = await loadSettings()
|
||||
|
||||
if (!settings.ok) {
|
||||
throw new Error(settings.message)
|
||||
}
|
||||
|
||||
const modelConfig = settings.modelConfig
|
||||
|
||||
if (!modelConfig?.baseUrl || !modelConfig.apiKey || !modelConfig.model) {
|
||||
throw new Error('请先在设置中配置模型接口、API Key 和模型')
|
||||
}
|
||||
|
||||
const requestUrl = getChatCompletionsUrl(modelConfig.baseUrl)
|
||||
const userPrompt = buildStudentCommentPrompt(profile)
|
||||
|
||||
logLargeModelRequest({
|
||||
label: '生成学生评语',
|
||||
url: requestUrl,
|
||||
apiKey: modelConfig.apiKey,
|
||||
model: modelConfig.model,
|
||||
prompt: userPrompt
|
||||
})
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${modelConfig.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelConfig.model,
|
||||
temperature: Number(modelConfig.temperature || 0.7),
|
||||
max_tokens: Number(modelConfig.maxTokens || 512),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
modelConfig.systemPrompt ||
|
||||
'你是一名幼儿园老师,擅长撰写真诚、具体、积极的成长报告评语。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: userPrompt
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`生成评语失败:HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = (await response.json()) as {
|
||||
choices?: Array<{ message?: { content?: unknown } }>
|
||||
}
|
||||
const comment = result.choices?.[0]?.message?.content
|
||||
|
||||
if (typeof comment !== 'string' || !comment.trim()) {
|
||||
throw new Error('模型没有返回可用评语')
|
||||
}
|
||||
|
||||
const nextProfile: ChildProfile = {
|
||||
...profile,
|
||||
comment: comment.trim(),
|
||||
commentGeneratedAt: new Date().toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
await repository.save(mapChildProfile(nextProfile))
|
||||
return nextProfile
|
||||
}
|
||||
|
||||
export async function saveClasses(classes: ClassProfile[]): Promise<void> {
|
||||
const source = await getAppDataSource()
|
||||
|
||||
await source.transaction(async (manager) => {
|
||||
const classRepository = manager.getRepository<ClassEntity>(ClassEntitySchema)
|
||||
const childRepository = manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
const nextClassIds = new Set(classes.map((classItem) => classItem.id))
|
||||
const existingClasses = await classRepository.find()
|
||||
|
||||
for (const classItem of existingClasses) {
|
||||
if (!nextClassIds.has(classItem.id)) {
|
||||
await childRepository.delete({ classId: classItem.id })
|
||||
await classRepository.delete({ id: classItem.id })
|
||||
}
|
||||
}
|
||||
|
||||
for (const classItem of classes) {
|
||||
await classRepository.save(mapClassProfile(classItem))
|
||||
await childRepository.update({ classId: classItem.id }, { className: classItem.name })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function migrateLocalStudentData(
|
||||
classes: ClassProfile[],
|
||||
profiles: ChildProfile[]
|
||||
): Promise<void> {
|
||||
const source = await getAppDataSource()
|
||||
|
||||
await source.transaction(async (manager) => {
|
||||
const classRepository = manager.getRepository<ClassEntity>(ClassEntitySchema)
|
||||
const childRepository = manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
const classCount = await classRepository.count()
|
||||
const profileCount = await childRepository.count()
|
||||
|
||||
if (classCount > 0 || profileCount > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await classRepository.save(classes.map(mapClassProfile))
|
||||
await childRepository.save(profiles.map((profile) => mapChildProfile(profile)))
|
||||
})
|
||||
}
|
||||
|
||||
export async function replaceProfilesForClass(
|
||||
classItem: ClassProfile,
|
||||
profiles: ChildProfile[]
|
||||
): Promise<void> {
|
||||
const source = await getAppDataSource()
|
||||
|
||||
await source.transaction(async (manager) => {
|
||||
const childRepository = manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
|
||||
await childRepository.delete({ classId: classItem.id })
|
||||
await childRepository.save(profiles.map((profile) => mapChildProfile(profile, classItem)))
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteClassWithProfiles(classId: string): Promise<void> {
|
||||
const source = await getAppDataSource()
|
||||
|
||||
await source.transaction(async (manager) => {
|
||||
await manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema).delete({ classId })
|
||||
await manager.getRepository<ClassEntity>(ClassEntitySchema).delete({ id: classId })
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteStudentProfile(profileId: string): Promise<void> {
|
||||
const source = await getAppDataSource()
|
||||
const result = await source
|
||||
.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
.delete({ id: profileId })
|
||||
|
||||
if (!result.affected) {
|
||||
throw new Error('学生信息不存在')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user