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 ? '保存后,学员可在成长档案中查看' : '当前仅馆主可见' }}
@@ -45,7 +45,7 @@
A MOMENT TO REMEMBER{{ n }}节{{ milestoneTitles[n] }}{{ archive.milestones.includes(n) ? '你已走过这段旅程' : '还差 ' + (n - archive.completedCount) + ' 节,一步步来' }}
-
+
添加成长照片
记录日期{{ 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 @@
+
+
+
+ 正在翻开这次练习的记录…
+
+ {{ state === 'login' ? '○' : '—' }}
+ {{ state === 'login' ? '登录后,查看你的课后评价' : state === 'denied' ? '这份评价暂时无法查看' : '暂时未能读取评价' }}
+ {{ state === 'login' ? '仅限这份评价对应的学员本人查看。' : state === 'denied' ? '请确认使用的是本人微信账号,且教练已开启学员查看。' : error }}
+
+
+ {{ error }}
+
+
+
+
+ AFTER CLASS · 练习手记
+ 每一份进步,都被看见。
+ {{ feedback.nickname }}的课后评价
+ {{ feedback.note.booking ? formatChinaDate(feedback.note.booking.timeSlot.date) : formatChinaDate(feedback.note.createdAt) }}{{ feedback.note.booking ? feedback.note.booking.timeSlot.startTime + ' · 本次课程' : '教练笔记' }}
+
+
+ 教练评价{{ apparatusCount ? apparatusCount + ' 项器械记录' : '整体练习反馈' }}
+ 整体评价{{ feedback.note.content }}
+
+ 记录于 {{ formatChinaDate(feedback.note.createdAt) }}
+
+ 仅限评价对应的学员本人登录查看
+
+ {{ shareError }}
+
+
+ 转发链接后,仍会验证学员身份
+
+
+
+ 当前为私密记录,分享前需开启该学员查看
+
+
+
+
+
+
+
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 0000000..ced7929
Binary files /dev/null and b/packages/app/src/static/share/lesson-feedback.png differ
diff --git a/packages/app/src/utils/lesson-feedback.ts b/packages/app/src/utils/lesson-feedback.ts
new file mode 100644
index 0000000..527a14e
--- /dev/null
+++ b/packages/app/src/utils/lesson-feedback.ts
@@ -0,0 +1,12 @@
+// Use a generic cover instead of WeChat's automatic page screenshot, which may
+// contain a member's training notes. Shared URLs never include an admin userId.
+export function lessonFeedbackShare(id?: string) {
+ return {
+ title: '教练为你记录了一份课后评价',
+ path: id ? `/pages/profile/lesson-feedback?id=${encodeURIComponent(id)}` : '/pages/home/index',
+ imageUrl: '/static/share/lesson-feedback.png',
+ }
+}
+export function adminLessonFeedbackUrl(userId: string, id: string) {
+ return `/pages/profile/lesson-feedback?id=${encodeURIComponent(id)}&userId=${encodeURIComponent(userId)}`
+}
diff --git a/packages/server/src/user/__tests__/lesson-feedback-sharing.controller.spec.ts b/packages/server/src/user/__tests__/lesson-feedback-sharing.controller.spec.ts
new file mode 100644
index 0000000..3100f2d
--- /dev/null
+++ b/packages/server/src/user/__tests__/lesson-feedback-sharing.controller.spec.ts
@@ -0,0 +1,110 @@
+import { ExecutionContext, INestApplication, UnauthorizedException, ValidationPipe } from '@nestjs/common'
+import { Test } from '@nestjs/testing'
+import type { AddressInfo } from 'node:net'
+import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
+import { RolesGuard } from '../../auth/roles.guard'
+import { PrismaService } from '../../prisma/prisma.service'
+import { ApiResponseInterceptor } from '../../common/interceptors/api-response.interceptor'
+import { AdminMemberProgressController, MemberProgressController } from '../member-progress.controller'
+import { MemberProgressService } from '../member-progress.service'
+import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
+import { lessonFeedbackShare } from '../../../../app/src/utils/lesson-feedback'
+
+describe('Owner-only shared lesson feedback HTTP access', () => {
+ 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
}