From b2a785b27b09bc98c3baa8d4e9f5932004702c74 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 18 Oct 2026 16:09:39 +0800 Subject: [PATCH] feat: share lesson feedback with owner-only access --- docs/lesson-apparatus-feedback.md | 14 ++ .../app/src/components/CoachNoteEditor.vue | 16 ++- .../app/src/components/MemberProgress.vue | 11 +- packages/app/src/pages.json | 6 + .../app/src/pages/profile/lesson-feedback.vue | 121 ++++++++++++++++++ .../app/src/static/share/lesson-feedback.png | Bin 0 -> 39405 bytes packages/app/src/utils/lesson-feedback.ts | 12 ++ ...lesson-feedback-sharing.controller.spec.ts | 110 ++++++++++++++++ .../src/user/dto/member-progress.dto.ts | 1 + .../src/user/member-progress.controller.ts | 7 +- .../src/user/member-progress.service.ts | 19 +++ packages/shared/src/types/member-care.ts | 4 + 12 files changed, 310 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/pages/profile/lesson-feedback.vue create mode 100644 packages/app/src/static/share/lesson-feedback.png create mode 100644 packages/app/src/utils/lesson-feedback.ts create mode 100644 packages/server/src/user/__tests__/lesson-feedback-sharing.controller.spec.ts diff --git a/docs/lesson-apparatus-feedback.md b/docs/lesson-apparatus-feedback.md index 4cb2586..857ccaf 100644 --- a/docs/lesson-apparatus-feedback.md +++ b/docs/lesson-apparatus-feedback.md @@ -22,3 +22,17 @@ 5. 默认私密记录仅馆主可见,开启共享后学员端按器械分段展示;未填写的器械不显示,历史整体评价仍显示。 本地已验证共享包构建、前端类型检查、小程序构建、后端构建,以及完整 Jest 测试。本地浏览器组件预览用于验证展开、切换、填写计数、仅器械保存及分段展示;不替代微信真机验收。 + +## 单条评价分享 + +保存后自动进入评价详情页;档案内每条评价底部的「查看与分享」也可进入。教练页底固定「分享给学员」主按钮,避免长评价滚动后找不到操作。私密记录首次点击会确认开启该学员查看,再次点击调起微信转发;可随时关闭学员查看。 + +- 分享仅携带评价 ID,跳转 `/pages/profile/lesson-feedback?id=...`,不带管理员预览参数 `userId`。 +- 分享标题与封面固定,不包含学员姓名、评价正文或页面截图。 +- 学员详情接口 `GET /user/progress/notes/:id` 强制登录,并在同一次数据库查询中限定 JWT 学员 ID、评价 ID 与 `shared=true`。管理员打开学生分享链接同样没有跨学员读取权限。 +- 教练预览和共享设置分别使用现有 ADMIN 权限控制下的 `GET /admin/members/:userId/progress/notes/:id` 与 `PUT .../notes/:id/sharing`;二者均限定目标学员与评价归属。共享修改仅接受布尔 `shared`。 +- 详情响应设置 `Cache-Control: private, no-store`,仅返回展示所需内容。无权限、已删除、未共享统一返回 404,不泄露评价是否存在。 +- 未登录先展示登录引导,登录后留在同一条评价;不显示任何姓名或正文预览。退出登录或页面进入后台时清除详情,返回页面重新请求和验证权限。 +- 关闭共享后旧链接不再能从接口读取;已经阅读、截图或自行保存的内容不能远程撤回。 + +本次分享功能不新增表、字段或环境变量,沿用已部署的器械评价字段。 diff --git a/packages/app/src/components/CoachNoteEditor.vue b/packages/app/src/components/CoachNoteEditor.vue index 01d1ea2..04debc3 100644 --- a/packages/app/src/components/CoachNoteEditor.vue +++ b/packages/app/src/components/CoachNoteEditor.vue @@ -32,7 +32,7 @@ {{ notes[apparatus]?.trim() }} - 分享给学员{{ shared ? '保存后,学员可在成长档案中查看' : '当前仅馆主可见' }} + 允许学员查看{{ shared ? '保存后,学员可在成长档案中查看' : '当前仅馆主可见' }} @@ -49,7 +49,7 @@ import { LESSON_APPARATUS, LESSON_NOTE_MAX_LENGTH, filledApparatusNotes } from ' import type { ApparatusNotes, LessonApparatus } from '@mp-pilates/shared' import { post } from '../utils/request' const props = defineProps<{ userId: string; bookingId?: string }>() -const emit = defineEmits<{ close: []; saved: [] }>() +const emit = defineEmits<{ close: []; saved: [id: string] }>() const content = ref(''), notes = ref({}), active = ref(null), shared = ref(false) const confirmingClose = ref(false) const busy = ref(false), closing = ref(false), error = ref('') @@ -59,10 +59,13 @@ const hasDraft = computed(() => !!content.value || Object.values(notes.value).so let closeTimer: ReturnType | undefined function toggle(apparatus: LessonApparatus) { active.value = active.value === apparatus ? null : apparatus } function changeShared(event: Event) { shared.value = (event as unknown as { detail: { value: boolean } }).detail.value } -function close() { +function close(savedId?: string) { uni.hideKeyboard() closing.value = true - closeTimer = setTimeout(() => emit('close'), 220) + closeTimer = setTimeout(() => { + emit('close') + if (savedId) emit('saved', savedId) + }, 220) } async function requestClose() { if (busy.value || closing.value || confirmingClose.value) return @@ -78,15 +81,14 @@ async function save() { busy.value = true error.value = '' try { - await post(`/admin/members/${props.userId}/progress/notes`, { + const note = await post<{ id: string }>(`/admin/members/${props.userId}/progress/notes`, { content: content.value.trim(), apparatusNotes: Object.fromEntries(filledApparatusNotes(notes.value).map(note => [note.apparatus, note.content])), shared: shared.value, ...(props.bookingId ? { bookingId: props.bookingId } : {}), }) - emit('saved') uni.showToast({ title: '课后评价已保存', icon: 'success' }) - close() + close(note.id) } catch (e) { error.value = e instanceof Error ? e.message : '保存失败,请重试' } finally { busy.value = false } } diff --git a/packages/app/src/components/MemberProgress.vue b/packages/app/src/components/MemberProgress.vue index 135c3a5..3a560a1 100644 --- a/packages/app/src/components/MemberProgress.vue +++ b/packages/app/src/components/MemberProgress.vue @@ -20,7 +20,7 @@ {{ note.content }} 课程 · {{ formatChinaDate(note.booking.timeSlot.date) }} {{ note.booking.timeSlot.startTime }} - {{ formatChinaDate(note.createdAt) }} + {{ formatChinaDate(note.createdAt) }} - + 添加成长照片 记录日期{{ recordDate }} › @@ -65,6 +65,7 @@ import ApparatusNoteList from './ApparatusNoteList.vue' import type { ProgressArchive, ProgressPhotoRecord } from '@mp-pilates/shared' import { get, post, del } from '../utils/request' import { formatChinaDate } from '../utils/format' +import { adminLessonFeedbackUrl } from '../utils/lesson-feedback' const props = defineProps<{ userId?: string; admin?: boolean; bookingId?: string; refreshKey?: number }>() const archive = ref(null), loading = ref(false), error = ref(''), tab = ref('metrics'), editor = ref(''), busy = ref(false), saveError = ref('') const tabs = [{ key: 'metrics', name: '体测' }, { key: 'notes', name: '寄语' }, { key: 'photos', name: '照片' }, { key: 'milestones', name: '里程碑' }] @@ -87,6 +88,11 @@ async function load() { catch (e) { if (seq === sequence) error.value = message(e) } finally { if (seq === sequence && isInitial) loading.value = false } } +function openNote(id: string) { + const url = props.admin && props.userId ? adminLessonFeedbackUrl(props.userId, id) : `/pages/profile/lesson-feedback?id=${encodeURIComponent(id)}` + uni.navigateTo({ url }) +} +function onNoteSaved(id: string) { void load(); openNote(id) } function previewComparison(url: string) { uni.previewImage({ urls: comparison.value.map(p => p.url), current: url }) } function message(e: unknown) { return e instanceof Error ? e.message : '操作失败,请重试' } function openEditor(kind: string) { @@ -172,6 +178,7 @@ onShow(() => { if (archive.value && !editor.value) void load() }) onBeforeUnmount(() => { sequence++; if (timer) clearTimeout(timer) }) diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json index 74ffeca..6073198 100644 --- a/packages/app/src/pages.json +++ b/packages/app/src/pages.json @@ -81,6 +81,12 @@ "navigationStyle": "custom" } }, + { + "path": "pages/profile/lesson-feedback", + "style": { + "navigationStyle": "custom" + } + }, { "path": "pages/profile/progress", "style": { diff --git a/packages/app/src/pages/profile/lesson-feedback.vue b/packages/app/src/pages/profile/lesson-feedback.vue new file mode 100644 index 0000000..a153a99 --- /dev/null +++ b/packages/app/src/pages/profile/lesson-feedback.vue @@ -0,0 +1,121 @@ + + + diff --git a/packages/app/src/static/share/lesson-feedback.png b/packages/app/src/static/share/lesson-feedback.png new file mode 100644 index 0000000000000000000000000000000000000000..ced7929cef981cc56c6b08a3b8d402dafe0398e1 GIT binary patch literal 39405 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 cbsYa01;tlzxy-231 { + let app: INestApplication + const original = { id: 'note', content: '整体评价', apparatusNotes: { M: '控制稳定', Chair: '注意节奏' }, shared: true, createdAt: new Date('2026-10-18T00:00:00Z'), bookingId: 'lesson', booking: { timeSlot: { date: new Date('2026-10-18'), startTime: '10:00' } }, user: { nickname: '学员' } } + let record: typeof original | null + const db = { memberNote: { findFirst: jest.fn(), updateMany: jest.fn() } } + async function request(method: string, path: string, identity?: string, body?: unknown, role = 'MEMBER') { + const address = app.getHttpServer().address() as AddressInfo + return fetch(`http://127.0.0.1:${address.port}${path}`, { method, headers: { 'Content-Type': 'application/json', ...(identity ? { 'x-user': identity, 'x-role': role } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) + } + beforeAll(async () => { + const module = await Test.createTestingModule({ + controllers: [MemberProgressController, AdminMemberProgressController], + providers: [MemberProgressService, RolesGuard, { provide: PrismaService, useValue: db }, { provide: ProgressPhotoStorageService, useValue: {} }], + }).overrideGuard(JwtAuthGuard).useValue({ canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest() + if (!req.headers['x-user']) throw new UnauthorizedException() + req.user = { sub: req.headers['x-user'], role: req.headers['x-role'] } + return true + } }).compile() + app = module.createNestApplication() + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })) + app.useGlobalInterceptors(new ApiResponseInterceptor()) + await app.listen(0, '127.0.0.1') + }) + beforeEach(() => { + jest.clearAllMocks() + record = structuredClone(original) + db.memberNote.findFirst.mockImplementation(async ({ where }) => record && where.id === record.id && where.userId === 'owner' && (where.shared === undefined || where.shared === record.shared) ? record : null) + db.memberNote.updateMany.mockImplementation(async ({ where, data }) => { + if (!record || where.userId !== 'owner' || where.id !== record.id) return { count: 0 } + record.shared = data.shared + return { count: 1 } + }) + }) + afterAll(async () => { await app.close() }) + + it('returns this one shared evaluation to its authenticated owner with no internal IDs', async () => { + const response = await request('GET', '/user/progress/notes/note', 'owner') + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('private, no-store') + const body = await response.json() + expect(body.data).toMatchObject({ nickname: '学员', note: { content: '整体评价', apparatusNotes: { M: '控制稳定', Chair: '注意节奏' } } }) + expect(body.data.note.operatorId).toBeUndefined() + expect(body.data.note.userId).toBeUndefined() + expect(db.memberNote.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'note', userId: 'owner', shared: true }, select: expect.not.objectContaining({ operatorId: true, userId: true }) })) + }) + it.each(['MEMBER', 'ADMIN'])('rejects a forwarded student link for a different %s account', async role => { + const response = await request('GET', '/user/progress/notes/note?userId=owner', 'other', undefined, role) + expect(response.status).toBe(404) + expect(JSON.stringify(await response.json())).not.toContain('控制稳定') + expect(db.memberNote.findFirst.mock.calls[0][0].where).toEqual({ id: 'note', userId: 'other', shared: true }) + }) + it.each(['private', 'deleted', 'unknown'])('does not reveal a %s note even to the owner', async kind => { + if (kind === 'private') record!.shared = false + if (kind === 'deleted') record = null + const response = await request('GET', `/user/progress/notes/${kind === 'unknown' ? 'missing' : 'note'}`, 'owner') + expect(response.status).toBe(404) + expect((await response.json()).message).toBe('评价不存在或你无权查看') + }) + it.each(['/user/progress/notes/note', '/admin/members/owner/progress/notes/note'])('requires authentication: %s', async path => { + expect((await request('GET', path)).status).toBe(401) + expect(db.memberNote.findFirst).not.toHaveBeenCalled() + }) + it('allows an authorized teacher preview of a private note, scoped to the target member', async () => { + record!.shared = false + expect((await request('GET', '/admin/members/owner/progress/notes/note', 'teacher', undefined, 'ADMIN')).status).toBe(200) + expect(db.memberNote.findFirst.mock.calls[0][0].where).toEqual({ id: 'note', userId: 'owner' }) + expect((await request('GET', '/admin/members/other/progress/notes/note', 'teacher', undefined, 'ADMIN')).status).toBe(404) + }) + it('does not let members obtain preview access or change sharing by forging an admin URL', async () => { + expect((await request('GET', '/admin/members/owner/progress/notes/note', 'other')).status).toBe(403) + expect((await request('PUT', '/admin/members/owner/progress/notes/note/sharing', 'owner', { shared: true })).status).toBe(403) + expect(db.memberNote.findFirst).not.toHaveBeenCalled() + expect(db.memberNote.updateMany).not.toHaveBeenCalled() + }) + it('revokes existing links immediately and lets a teacher re-enable the same note', async () => { + const endpoint = '/admin/members/owner/progress/notes/note/sharing' + expect((await request('PUT', endpoint, 'teacher', { shared: false }, 'ADMIN')).status).toBe(200) + expect((await request('GET', '/user/progress/notes/note', 'owner')).status).toBe(404) + expect((await request('PUT', endpoint, 'teacher', { shared: true }, 'ADMIN')).status).toBe(200) + expect((await request('GET', '/user/progress/notes/note', 'owner')).status).toBe(200) + expect(record!.apparatusNotes).toEqual(original.apparatusNotes) + expect(db.memberNote.updateMany).toHaveBeenCalledWith({ where: { id: 'note', userId: 'owner' }, data: { shared: false } }) + }) + it.each([{}, { shared: 'true' }, { shared: 1 }, { shared: null }, { shared: true, userId: 'other' }])('rejects malformed sharing updates %j', async body => { + expect((await request('PUT', '/admin/members/owner/progress/notes/note/sharing', 'teacher', body, 'ADMIN')).status).toBe(400) + expect(db.memberNote.updateMany).not.toHaveBeenCalled() + }) + it('does not change a note through a mismatched member path', async () => { + expect((await request('PUT', '/admin/members/other/progress/notes/note/sharing', 'teacher', { shared: false }, 'ADMIN')).status).toBe(404) + expect(record!.shared).toBe(true) + }) + it('shares a student-only URL with a generic title and static cover instead of personal content', () => { + expect(lessonFeedbackShare('note')).toEqual({ title: '教练为你记录了一份课后评价', path: '/pages/profile/lesson-feedback?id=note', imageUrl: '/static/share/lesson-feedback.png' }) + expect(lessonFeedbackShare('note&userId=owner').path).toBe('/pages/profile/lesson-feedback?id=note%26userId%3Downer') + expect(lessonFeedbackShare().path).toBe('/pages/home/index') + }) +}) diff --git a/packages/server/src/user/dto/member-progress.dto.ts b/packages/server/src/user/dto/member-progress.dto.ts index 5b56841..d58a681 100644 --- a/packages/server/src/user/dto/member-progress.dto.ts +++ b/packages/server/src/user/dto/member-progress.dto.ts @@ -19,6 +19,7 @@ export class MemberNoteDto { @IsBoolean() shared!: boolean @IsOptional() @IsString() @MinLength(1) bookingId?: string } +export class NoteSharingDto { @IsBoolean() shared!: boolean } export class ProgressPhotoDto { @IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string @IsString() @MaxLength(200) caption!: string diff --git a/packages/server/src/user/member-progress.controller.ts b/packages/server/src/user/member-progress.controller.ts index fe9a809..50b34e5 100644 --- a/packages/server/src/user/member-progress.controller.ts +++ b/packages/server/src/user/member-progress.controller.ts @@ -1,15 +1,16 @@ -import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common' +import { Body, Controller, Delete, Get, Header, Param, Post, Put, UseGuards } from '@nestjs/common' import { UserRole } from '@mp-pilates/shared' import { JwtAuthGuard } from '../auth/jwt-auth.guard' import { RolesGuard } from '../auth/roles.guard' import { Roles } from '../auth/roles.decorator' import { CurrentUser } from '../common/decorators/current-user.decorator' import { MemberProgressService } from './member-progress.service' -import { BodyMetricDto, MemberNoteDto, PhotoConsentDto, ProgressPhotoDto } from './dto/member-progress.dto' +import { BodyMetricDto, MemberNoteDto, NoteSharingDto, PhotoConsentDto, ProgressPhotoDto } from './dto/member-progress.dto' @Controller('user/progress') @UseGuards(JwtAuthGuard) export class MemberProgressController { constructor(private readonly service: MemberProgressService) {} @Get() archive(@CurrentUser('sub') userId: string) { return this.service.archive(userId) } + @Get('notes/:id') @Header('Cache-Control', 'private, no-store') note(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.note(userId, id) } @Get('metrics/:id') metric(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) } @Post('photos/:id/consent') consent(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: PhotoConsentDto) { return this.service.consent(userId, id, dto.consent) } @Get('photos/:id/url') url(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id) } @@ -23,6 +24,8 @@ export class AdminMemberProgressController { @Get('metrics/:id') metricDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) } @Put('metrics/:id') updateMetric(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.updateMetric(userId, id, operatorId, dto) } @Post('notes') note(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.addNote(userId, operatorId, dto) } + @Get('notes/:id') @Header('Cache-Control', 'private, no-store') noteDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.note(userId, id, true) } + @Put('notes/:id/sharing') shareNote(@Param('userId') userId: string, @Param('id') id: string, @Body() dto: NoteSharingDto) { return this.service.setNoteSharing(userId, id, dto.shared) } @Delete(':kind/:id') remove(@Param('userId') userId: string, @Param('kind') kind: string, @Param('id') id: string) { return this.service.remove(userId, kind, id) } @Post('photos/upload') upload(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: ProgressPhotoDto) { return this.service.upload(userId, operatorId, dto) } @Post('photos/:id/complete') complete(@Param('userId') userId: string, @Param('id') id: string) { return this.service.finishUpload(userId, id) } diff --git a/packages/server/src/user/member-progress.service.ts b/packages/server/src/user/member-progress.service.ts index 0c60faa..6c974bf 100644 --- a/packages/server/src/user/member-progress.service.ts +++ b/packages/server/src/user/member-progress.service.ts @@ -61,6 +61,25 @@ export class MemberProgressService { if (dto.bookingId && !await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId, status: 'COMPLETED' } })) throw new BadRequestException('只能批注该学员已完成的课程') return this.prisma.memberNote.create({ data: { userId, operatorId, content, apparatusNotes, shared: dto.shared, bookingId: dto.bookingId || null } }) } + async note(userId: string, id: string, admin = false) { + const record = await this.prisma.memberNote.findFirst({ + where: { id, userId, ...(admin ? {} : { shared: true }) }, + select: { + id: true, content: true, apparatusNotes: true, shared: true, createdAt: true, bookingId: true, + booking: { select: { timeSlot: { select: { date: true, startTime: true } } } }, + user: { select: { nickname: true } }, + }, + }) + // A forwarded link must not reveal whether someone else's note exists. + if (!record) throw new NotFoundException('评价不存在或你无权查看') + const { user, ...note } = record + return { nickname: user.nickname, note } + } + async setNoteSharing(userId: string, id: string, shared: boolean) { + const result = await this.prisma.memberNote.updateMany({ where: { id, userId }, data: { shared } }) + if (!result.count) throw new NotFoundException('评价不存在或不属于该学员') + return { shared } + } async remove(userId: string, kind: string, id: string) { if (kind === 'metrics') { const result = await this.prisma.bodyMetric.deleteMany({ where: { id, userId } }) diff --git a/packages/shared/src/types/member-care.ts b/packages/shared/src/types/member-care.ts index fc7afce..1834c2e 100644 --- a/packages/shared/src/types/member-care.ts +++ b/packages/shared/src/types/member-care.ts @@ -17,6 +17,10 @@ export interface MemberNoteRecord { apparatusNotes?: import('../lesson-feedback').ApparatusNotes | null booking: { timeSlot: { date: string; startTime: string } } | null } +export interface LessonFeedbackDetail { + nickname: string + note: MemberNoteRecord +} export interface ProgressPhotoRecord { id: string; caption: string; recordedAt: string; consentedAt: string | null; revokedAt: string | null; angle?: string | null; sessionId?: string | null }