|
|
@@ -112,11 +112,16 @@ const phonePassword = z.object({
|
|
|
})
|
|
|
|
|
|
const fullCourse = async (row: any, includeCreator = false) => {
|
|
|
- const [a, i, d, e] = await Promise.all([
|
|
|
+ const latest = await pool.query('SELECT * FROM courses WHERE id = $1', [row.id])
|
|
|
+ row = { ...row, ...(latest.rows[0] || {}) }
|
|
|
+ const [a, i, instructorImages, d, e, progress] = await Promise.all([
|
|
|
pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
|
|
|
pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
|
|
|
+ pool.query(`SELECT ii.* FROM instructor_images ii JOIN instructors i ON i.id = ii.instructor_id
|
|
|
+ WHERE i.course_id = $1 ORDER BY ii.sort_order, ii.created_at`, [row.id]),
|
|
|
pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id]),
|
|
|
- pool.query('SELECT * FROM episodes WHERE course_id = $1 ORDER BY episode_number ASC, created_at ASC', [row.id])
|
|
|
+ pool.query('SELECT * FROM episodes WHERE course_id = $1 ORDER BY episode_number ASC, created_at ASC', [row.id]),
|
|
|
+ pool.query('SELECT * FROM course_progress_events WHERE course_id = $1 ORDER BY created_at DESC', [row.id])
|
|
|
])
|
|
|
|
|
|
let episodesRows = e.rows
|
|
|
@@ -136,7 +141,30 @@ const fullCourse = async (row: any, includeCreator = false) => {
|
|
|
const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
|
|
|
creatorUser = u.rows[0] || null
|
|
|
}
|
|
|
- return mapCourse(row, a.rows, i.rows, d.rows, creatorUser, episodesRows)
|
|
|
+ const instructors = i.rows.map((instructor) => ({
|
|
|
+ ...instructor,
|
|
|
+ image_urls: instructorImages.rows
|
|
|
+ .filter((image) => image.instructor_id === instructor.id)
|
|
|
+ .map((image) => `/uploads/${image.stored_name}`)
|
|
|
+ }))
|
|
|
+ return mapCourse({ ...row, progress_events: progress.rows }, a.rows, instructors, d.rows, creatorUser, episodesRows)
|
|
|
+}
|
|
|
+
|
|
|
+const addProgressEvent = (courseId: string, actorType: 'USER' | 'ADMIN' | 'SYSTEM', eventType: string, title: string, description = '', metadata: any = {}) =>
|
|
|
+ pool.query(
|
|
|
+ 'INSERT INTO course_progress_events(id, course_id, actor_type, event_type, title, description, metadata) VALUES($1,$2,$3,$4,$5,$6,$7)',
|
|
|
+ [randomUUID(), courseId, actorType, eventType, title, description, JSON.stringify(metadata)]
|
|
|
+ )
|
|
|
+
|
|
|
+async function refreshCoursePoints(courseId: string) {
|
|
|
+ const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM episodes WHERE course_id = $1', [courseId])
|
|
|
+ const episodeCount = Math.max(1, Number(rows[0]?.count) || 0)
|
|
|
+ const points = episodeCount * 1000
|
|
|
+ await pool.query(
|
|
|
+ 'UPDATE courses SET estimated_points = $1, updated_at = NOW() WHERE id = $2',
|
|
|
+ [points, courseId]
|
|
|
+ )
|
|
|
+ return { episodeCount, points }
|
|
|
}
|
|
|
|
|
|
const ownedCourse = async (id: string, uid: string) =>
|
|
|
@@ -153,7 +181,7 @@ app.post('/api/auth/register', async (req, reply) => {
|
|
|
const id = randomUUID()
|
|
|
const hash = await hashPassword(p.data.password)
|
|
|
const { rows } = await pool.query(
|
|
|
- 'INSERT INTO users(id, phone, password_hash, role) VALUES($1, $2, $3, $4) RETURNING *',
|
|
|
+ 'INSERT INTO users(id, phone, password_hash, role, points_balance) VALUES($1, $2, $3, $4, 10000) RETURNING *',
|
|
|
[id, p.data.phone, hash, 'USER']
|
|
|
)
|
|
|
return reply.code(201).send({ token: await session(id), user: mapUser(rows[0]) })
|
|
|
@@ -235,7 +263,6 @@ app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
|
|
|
const courseInput = z.object({
|
|
|
name: z.string().trim().min(1, '请输入课程名称').max(100),
|
|
|
category: z.string().max(50).default(''),
|
|
|
- audience: z.string().max(100).default(''),
|
|
|
description: z.string().max(500).default('')
|
|
|
})
|
|
|
|
|
|
@@ -254,8 +281,8 @@ app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
|
|
|
const id = randomUUID()
|
|
|
const d = p.data
|
|
|
const { rows } = await pool.query(
|
|
|
- 'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
|
|
|
- [id, userId(req), d.name, d.category, d.audience, d.description]
|
|
|
+ 'INSERT INTO courses(id, user_id, name, category, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
|
|
|
+ [id, userId(req), d.name, d.category, d.description]
|
|
|
)
|
|
|
|
|
|
// 课程创建后默认创建第 1 集内容,确保分集不为空
|
|
|
@@ -266,6 +293,8 @@ app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
|
|
|
[defaultEpId, id, defaultTitle, '', '']
|
|
|
)
|
|
|
|
|
|
+ await addProgressEvent(id, 'USER', 'COURSE_CREATED', '课程已创建', '课程草稿创建成功。')
|
|
|
+
|
|
|
return reply.code(201).send({ course: await fullCourse(rows[0]) })
|
|
|
})
|
|
|
|
|
|
@@ -279,12 +308,15 @@ app.post('/api/courses/template', { preHandler: auth }, async (req, reply) => {
|
|
|
const courseId = randomUUID()
|
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
- 'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
|
|
|
- [courseId, userId(req), template.name, template.category, template.audience || '', template.description]
|
|
|
+ 'INSERT INTO courses(id, user_id, name, category, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
|
|
|
+ [courseId, userId(req), template.name, template.category, template.description]
|
|
|
)
|
|
|
|
|
|
await populateCourseFromTemplate(pool, courseId, template.id, uploadDir, sampleDir)
|
|
|
|
|
|
+ await refreshCoursePoints(courseId)
|
|
|
+ await addProgressEvent(courseId, 'USER', 'COURSE_CREATED', '从模板创建课程', '已按模板生成课程内容。')
|
|
|
+
|
|
|
return reply.code(201).send({ course: await fullCourse(rows[0]) })
|
|
|
})
|
|
|
|
|
|
@@ -295,6 +327,41 @@ app.get('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
|
|
|
return { course: await fullCourse(row) }
|
|
|
})
|
|
|
|
|
|
+app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
|
|
|
+ const { id } = req.params as any
|
|
|
+ const row = await ownedCourse(id, userId(req))
|
|
|
+ if (!row) return reply.code(404).send({ message: '课程不存在' })
|
|
|
+
|
|
|
+ const p = courseInput.pick({ name: true, category: true, description: true }).safeParse(req.body)
|
|
|
+ if (!p.success) {
|
|
|
+ return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
|
|
|
+ }
|
|
|
+
|
|
|
+ const { rows } = await pool.query(
|
|
|
+ 'UPDATE courses SET name = $1, category = $2, description = $3, updated_at = NOW() WHERE id = $4 AND user_id = $5 RETURNING *',
|
|
|
+ [p.data.name, p.data.category, p.data.description, id, userId(req)]
|
|
|
+ )
|
|
|
+ await refreshCoursePoints(id)
|
|
|
+ await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、分类或需求说明已更新。')
|
|
|
+ return { course: await fullCourse(rows[0]) }
|
|
|
+})
|
|
|
+
|
|
|
+app.post('/api/courses/:id/cancel-submission', { preHandler: auth }, async (req, reply) => {
|
|
|
+ const { id } = req.params as any
|
|
|
+ const row = await ownedCourse(id, userId(req))
|
|
|
+ if (!row) return reply.code(404).send({ message: '课程不存在' })
|
|
|
+ if (row.status !== 'WAITING_PRODUCTION') {
|
|
|
+ return reply.code(409).send({ message: '只有等待制作的课程可以恢复为草稿' })
|
|
|
+ }
|
|
|
+
|
|
|
+ const { rows } = await pool.query(
|
|
|
+ "UPDATE courses SET status = 'DRAFT', submitted_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2 RETURNING *",
|
|
|
+ [id, userId(req)]
|
|
|
+ )
|
|
|
+ await addProgressEvent(id, 'USER', 'SUBMISSION_CANCELLED', '已撤回制作申请', '课程恢复为草稿,可继续修改后重新提交。')
|
|
|
+ return { course: await fullCourse(rows[0]) }
|
|
|
+})
|
|
|
+
|
|
|
app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
|
|
|
const { id } = req.params as any
|
|
|
const row = await ownedCourse(id, userId(req))
|
|
|
@@ -313,8 +380,16 @@ app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
|
|
|
|
|
|
// 2. 清理讲师头像图片
|
|
|
const instRes = await pool.query('SELECT image_stored_name FROM instructors WHERE course_id = $1', [id])
|
|
|
+ const instImagesRes = await pool.query(
|
|
|
+ 'SELECT ii.stored_name FROM instructor_images ii JOIN instructors i ON i.id = ii.instructor_id WHERE i.course_id = $1',
|
|
|
+ [id]
|
|
|
+ )
|
|
|
+ const instructorStoredNames = new Set(instImagesRes.rows.map((image) => image.stored_name))
|
|
|
for (const ins of instRes.rows) {
|
|
|
- if (ins.image_stored_name) await unlink(resolve(uploadDir, ins.image_stored_name)).catch(() => {})
|
|
|
+ if (ins.image_stored_name) instructorStoredNames.add(ins.image_stored_name)
|
|
|
+ }
|
|
|
+ for (const storedName of instructorStoredNames) {
|
|
|
+ await unlink(resolve(uploadDir, storedName as string)).catch(() => {})
|
|
|
}
|
|
|
|
|
|
// 3. 清理交付成品文件
|
|
|
@@ -424,6 +499,9 @@ app.post('/api/courses/:id/episodes', { preHandler: auth }, async (req, reply) =
|
|
|
[epId, id, epNumber, p.data.title, p.data.summary, p.data.lectureNotes]
|
|
|
)
|
|
|
|
|
|
+ await refreshCoursePoints(id)
|
|
|
+ await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '新增课程分集', '课程结构已更新。')
|
|
|
+
|
|
|
return reply.code(201).send({ course: await fullCourse(row) })
|
|
|
})
|
|
|
|
|
|
@@ -470,6 +548,9 @@ app.post('/api/courses/:id/episodes/batch', { preHandler: auth }, async (req, re
|
|
|
return reply.code(400).send({ message: '请指定集数或分集列表' })
|
|
|
}
|
|
|
|
|
|
+ await refreshCoursePoints(id)
|
|
|
+ await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '批量新增课程分集', '课程结构已更新。')
|
|
|
+
|
|
|
return reply.code(201).send({ course: await fullCourse(row) })
|
|
|
})
|
|
|
|
|
|
@@ -478,7 +559,9 @@ app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (r
|
|
|
const { id, episodeId } = req.params as any
|
|
|
const row = await ownedCourse(id, userId(req))
|
|
|
if (!row) return reply.code(404).send({ message: '课程不存在' })
|
|
|
- if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改分集' })
|
|
|
+ if (!['DRAFT', 'WAITING_PRODUCTION'].includes(row.status)) {
|
|
|
+ return reply.code(409).send({ message: '课程已进入制作或完成归档,无法修改分集' })
|
|
|
+ }
|
|
|
|
|
|
const p = z.object({
|
|
|
title: z.string().trim().min(1, '标题不能为空').max(200).optional(),
|
|
|
@@ -503,6 +586,9 @@ app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (r
|
|
|
[newTitle, newNumber, newSummary, newNotes, episodeId, id]
|
|
|
)
|
|
|
|
|
|
+ await refreshCoursePoints(id)
|
|
|
+ await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程内容已修改', '分集内容已更新。')
|
|
|
+
|
|
|
return { course: await fullCourse(row) }
|
|
|
})
|
|
|
|
|
|
@@ -526,6 +612,8 @@ app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (
|
|
|
}
|
|
|
|
|
|
await pool.query('DELETE FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
|
|
|
+ await refreshCoursePoints(id)
|
|
|
+ await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '删除课程分集', '课程结构已更新。')
|
|
|
return { course: await fullCourse(row) }
|
|
|
})
|
|
|
|
|
|
@@ -535,14 +623,14 @@ app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply
|
|
|
if (!row) return reply.code(404).send({ message: '课程不存在' })
|
|
|
if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
|
|
|
const fields: any = {}
|
|
|
- let image: any = null
|
|
|
+ const images: any[] = []
|
|
|
for await (const part of req.parts()) {
|
|
|
if (part.type === 'file') {
|
|
|
if (!part.mimetype.startsWith('image/')) return reply.code(400).send({ message: '讲师图片必须是图片格式' })
|
|
|
const ext = extname(part.filename).slice(0, 12)
|
|
|
const stored = 'instructor-' + randomUUID() + ext
|
|
|
await pipeline(part.file, createWriteStream(resolve(uploadDir, stored)))
|
|
|
- image = { original: part.filename, stored, mime: part.mimetype }
|
|
|
+ images.push({ original: part.filename, stored, mime: part.mimetype })
|
|
|
} else {
|
|
|
fields[part.fieldname] = part.value
|
|
|
}
|
|
|
@@ -555,14 +643,16 @@ app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply
|
|
|
})
|
|
|
.safeParse(fields)
|
|
|
if (!p.success) {
|
|
|
- if (image) await unlink(resolve(uploadDir, image.stored)).catch(() => {})
|
|
|
+ await Promise.all(images.map((image) => unlink(resolve(uploadDir, image.stored)).catch(() => {})))
|
|
|
return reply.code(400).send({ message: '请填写讲师姓名' })
|
|
|
}
|
|
|
const d = p.data
|
|
|
+ const instructorId = randomUUID()
|
|
|
+ const image = images[0]
|
|
|
const { rows } = await pool.query(
|
|
|
'INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *',
|
|
|
[
|
|
|
- randomUUID(),
|
|
|
+ instructorId,
|
|
|
id,
|
|
|
d.name,
|
|
|
d.organization,
|
|
|
@@ -572,6 +662,12 @@ app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply
|
|
|
image?.mime || null
|
|
|
]
|
|
|
)
|
|
|
+ for (const [index, uploadedImage] of images.entries()) {
|
|
|
+ await pool.query(
|
|
|
+ 'INSERT INTO instructor_images(id, instructor_id, original_name, stored_name, mime_type, sort_order) VALUES($1, $2, $3, $4, $5, $6)',
|
|
|
+ [randomUUID(), instructorId, uploadedImage.original, uploadedImage.stored, uploadedImage.mime, index]
|
|
|
+ )
|
|
|
+ }
|
|
|
return reply.code(201).send({ instructor: mapInstructor(rows[0]), course: await fullCourse(row) })
|
|
|
})
|
|
|
|
|
|
@@ -580,21 +676,29 @@ app.delete('/api/courses/:id/instructors/:instructorId', { preHandler: auth }, a
|
|
|
const row = await ownedCourse(id, userId(req))
|
|
|
if (!row) return reply.code(404).send({ message: '课程不存在' })
|
|
|
if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
|
|
|
+ const imageRows = await pool.query('SELECT stored_name FROM instructor_images WHERE instructor_id = $1', [instructorId])
|
|
|
const { rows } = await pool.query(
|
|
|
'DELETE FROM instructors WHERE id = $1 AND course_id = $2 RETURNING image_stored_name',
|
|
|
[instructorId, id]
|
|
|
)
|
|
|
- if (rows[0]?.image_stored_name) await unlink(resolve(uploadDir, rows[0].image_stored_name)).catch(() => {})
|
|
|
+ const storedNames = new Set(imageRows.rows.map((image) => image.stored_name))
|
|
|
+ if (rows[0]?.image_stored_name) storedNames.add(rows[0].image_stored_name)
|
|
|
+ await Promise.all([...storedNames].map((storedName) => unlink(resolve(uploadDir, storedName as string)).catch(() => {})))
|
|
|
return { course: await fullCourse(row) }
|
|
|
})
|
|
|
|
|
|
app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
|
|
|
const { id } = req.params as any
|
|
|
+ const estimate = await refreshCoursePoints(id)
|
|
|
+ const balance = Number((req as any).user.points_balance) || 0
|
|
|
+ const estimatedPoints = estimate.points
|
|
|
+ if (balance < estimatedPoints) return reply.code(409).send({ message: `积分不足:本课程需要 ${estimatedPoints} 积分,当前余额 ${balance} 积分` })
|
|
|
const { rows } = await pool.query(
|
|
|
"UPDATE courses SET status = 'WAITING_PRODUCTION', submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status = 'DRAFT' RETURNING *",
|
|
|
[id, userId(req)]
|
|
|
)
|
|
|
if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' })
|
|
|
+ await addProgressEvent(id, 'USER', 'SUBMITTED', '已提交制作', '课程已进入制作队列。')
|
|
|
return { course: await fullCourse(rows[0]) }
|
|
|
})
|
|
|
|
|
|
@@ -703,12 +807,28 @@ app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req,
|
|
|
status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'])
|
|
|
}).safeParse(req.body)
|
|
|
if (!p.success) return reply.code(400).send({ message: '无效的课程状态' })
|
|
|
+ if (p.data.status === 'COMPLETED') return reply.code(400).send({ message: '请通过“确认交付”完成课程并结算积分' })
|
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
'UPDATE courses SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
|
|
|
[p.data.status, id]
|
|
|
)
|
|
|
if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
|
|
|
+ const statusNames: Record<string, string> = { DRAFT: '草稿', WAITING_PRODUCTION: '等待制作', IN_PRODUCTION: '正在制作', COMPLETED: '已完成', REJECTED: '已驳回' }
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'STATUS_CHANGED', '制作状态已更新', `管理员将课程状态更新为“${statusNames[p.data.status]}”。`, { status: p.data.status })
|
|
|
+ return { course: await fullCourse(rows[0], true) }
|
|
|
+})
|
|
|
+
|
|
|
+app.patch('/api/admin/courses/:id/points', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
+ const { id } = req.params as any
|
|
|
+ const p = z.object({ points: z.number().int().min(1).max(100000), reason: z.string().max(500).default('根据实际制作需求调整') }).safeParse(req.body)
|
|
|
+ if (!p.success) return reply.code(400).send({ message: '请输入有效的积分消耗(至少 1 积分)' })
|
|
|
+ const previous = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
|
|
|
+ if (!previous.rows[0]) return reply.code(404).send({ message: '课程不存在' })
|
|
|
+ if (previous.rows[0].points_charged) return reply.code(409).send({ message: '课程已完成扣费,不能再修改积分' })
|
|
|
+ const { rows } = await pool.query('UPDATE courses SET actual_points = $1, updated_at = NOW() WHERE id = $2 RETURNING *', [p.data.points, id])
|
|
|
+ const oldPoints = previous.rows[0].actual_points ?? previous.rows[0].estimated_points
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'POINTS_UPDATED', '积分消耗已调整', `管理员将制作消耗从 ${oldPoints} 积分调整为 ${p.data.points} 积分。${p.data.reason}`, { previousPoints: Number(oldPoints), points: p.data.points })
|
|
|
return { course: await fullCourse(rows[0], true) }
|
|
|
})
|
|
|
|
|
|
@@ -756,6 +876,8 @@ app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'DELIVERABLE_UPLOADED', '管理员上传了制作成果', `新增 ${fileList.length} 个交付文件。`)
|
|
|
+
|
|
|
return { course: await fullCourse(check.rows[0], true) }
|
|
|
})
|
|
|
|
|
|
@@ -781,11 +903,23 @@ app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (re
|
|
|
}).safeParse(req.body || {})
|
|
|
if (!p.success) return reply.code(400).send({ message: '交付说明格式不正确' })
|
|
|
|
|
|
- const { rows } = await pool.query(
|
|
|
- "UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2 RETURNING *",
|
|
|
- [p.data.productionNotes, id]
|
|
|
- )
|
|
|
- if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
|
|
|
+ const client = await pool.connect()
|
|
|
+ let rows: any[] = []
|
|
|
+ try {
|
|
|
+ await client.query('BEGIN')
|
|
|
+ const current = await client.query('SELECT * FROM courses WHERE id = $1 FOR UPDATE', [id])
|
|
|
+ if (!current.rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '课程不存在' }) }
|
|
|
+ const cost = Number(current.rows[0].actual_points ?? current.rows[0].estimated_points) || 100
|
|
|
+ if (!current.rows[0].points_charged) {
|
|
|
+ const charged = await client.query('UPDATE users SET points_balance = points_balance - $1, updated_at = NOW() WHERE id = $2 AND points_balance >= $1 RETURNING points_balance', [cost, current.rows[0].user_id])
|
|
|
+ if (!charged.rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: `用户积分不足,完成制作需要扣除 ${cost} 积分` }) }
|
|
|
+ }
|
|
|
+ const updated = await client.query("UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), points_charged = TRUE, updated_at = NOW() WHERE id = $2 RETURNING *", [p.data.productionNotes, id])
|
|
|
+ rows = updated.rows
|
|
|
+ await client.query('COMMIT')
|
|
|
+ } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
|
|
+ const cost = Number(rows[0].actual_points ?? rows[0].estimated_points) || 100
|
|
|
+ await addProgressEvent(id, 'ADMIN', 'COMPLETED', '课程制作完成', `课程已交付,实际扣除 ${cost} 积分。`, { points: cost })
|
|
|
return { course: await fullCourse(rows[0], true) }
|
|
|
})
|
|
|
|
|
|
@@ -823,7 +957,62 @@ app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
|
|
|
return { users }
|
|
|
})
|
|
|
|
|
|
-// 9. 修改用户角色(赋予或撤销管理员权限)
|
|
|
+// 9. 管理员创建普通用户
|
|
|
+app.post('/api/admin/users', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
+ const p = z.object({
|
|
|
+ phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
|
|
|
+ password: z.string().min(6, '密码至少 6 位').max(72),
|
|
|
+ contactName: z.string().trim().max(50, '姓名不能超过 50 个字').optional().default(''),
|
|
|
+ organization: z.string().trim().max(100, '单位不能超过 100 个字').optional().default('')
|
|
|
+ }).safeParse(req.body)
|
|
|
+ if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
|
|
|
+
|
|
|
+ try {
|
|
|
+ const id = randomUUID()
|
|
|
+ const passwordHash = await hashPassword(p.data.password)
|
|
|
+ const { rows } = await pool.query(
|
|
|
+ `INSERT INTO users(id, phone, password_hash, role, contact_name, organization, points_balance)
|
|
|
+ VALUES($1, $2, $3, 'USER', $4, $5, 10000) RETURNING *`,
|
|
|
+ [id, p.data.phone, passwordHash, p.data.contactName, p.data.organization]
|
|
|
+ )
|
|
|
+ return reply.code(201).send({ user: mapUser(rows[0]) })
|
|
|
+ } catch (e: any) {
|
|
|
+ if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
|
|
|
+ throw e
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 10. 管理员重置用户密码;同时注销该用户已有会话
|
|
|
+app.patch('/api/admin/users/:id/password', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
+ const { id } = req.params as any
|
|
|
+ const p = z.object({
|
|
|
+ password: z.string().min(6, '密码至少 6 位').max(72)
|
|
|
+ }).safeParse(req.body)
|
|
|
+ if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
|
|
|
+
|
|
|
+ const client = await pool.connect()
|
|
|
+ try {
|
|
|
+ await client.query('BEGIN')
|
|
|
+ const { rowCount } = await client.query(
|
|
|
+ 'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
|
|
|
+ [await hashPassword(p.data.password), id]
|
|
|
+ )
|
|
|
+ if (!rowCount) {
|
|
|
+ await client.query('ROLLBACK')
|
|
|
+ return reply.code(404).send({ message: '用户不存在' })
|
|
|
+ }
|
|
|
+ await client.query('DELETE FROM sessions WHERE user_id = $1', [id])
|
|
|
+ await client.query('COMMIT')
|
|
|
+ return { ok: true }
|
|
|
+ } catch (error) {
|
|
|
+ await client.query('ROLLBACK')
|
|
|
+ throw error
|
|
|
+ } finally {
|
|
|
+ client.release()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 11. 修改用户角色(赋予或撤销管理员权限)
|
|
|
app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => {
|
|
|
const { id } = req.params as any
|
|
|
const currentAdmin = (req as any).user
|