diff --git a/CLAUDE.md b/CLAUDE.md
index 8b26554..1b7c26d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -120,3 +120,10 @@ pnpm deploy:server # 部署后端到生产环境
- 评分只在服务端规则引擎计算;匿名测评用访问令牌哈希,不用可枚举 ID 做权限。
- 安全分流不计分。完整报告需登录认领,体验预约需手机号。现有 TRIAL 体验卡承接转化。
- 馆主「今日经营助手」只展示待办,不做成通用 CRM。主包不得引用 admin store。
+
+### 教学案例展示
+- 归属 studio 模块:teaching-case 服务、控制器、DTO 与 __tests__;共享契约放 shared/src/types/teaching-case.ts。公开页面放 pages/home,管理页沿用 pages/admin,公共展示组件放 components。
+- 独立于私密成长档案;不得复用 progress/ 照片。教学案例上传使用 teaching-cases/ 私有对象、资产登记和完成校验;公开接口仅为已发布且已确认公开授权的案例签发短时图片链接。
+- 案例支持 1–9 组双图对比或已合成单图,课时为正整数(节);标题、化名、练习背景和变化说明不作疗效承诺。授权记录只对管理员可见,发布需确认学员已单独同意公开宣传。
+- 保存采用版本校验;撤回授权自动下架。游客首页优先展示最多 3 个案例,更多通过分页浏览;登录用户预约入口优先。无已发布案例时隐藏模块,故障不阻断首页。
+- 迁移目录仅放 migration.sql;发布、回退、图片清理和真机验收约定放 docs/teaching-cases.md。未引用资产保留供重试,清理前核对引用且不得删除有效案例图片。
diff --git a/deploy-server.sh b/deploy-server.sh
index 5ca76d6..f0d19d8 100755
--- a/deploy-server.sh
+++ b/deploy-server.sh
@@ -81,6 +81,7 @@ rsync -avz --delete \
--exclude '.env.local' \
--exclude '.DS_Store' \
--exclude '*.log' \
+ --exclude 'logs' \
--exclude 'src' \
--exclude '__tests__' \
--exclude 'test' \
diff --git a/docs/teaching-cases.md b/docs/teaching-cases.md
new file mode 100644
index 0000000..d7de00a
--- /dev/null
+++ b/docs/teaching-cases.md
@@ -0,0 +1,84 @@
+# 教学案例展示
+
+## 功能与入口
+
+- 管理中心 → 教学案例展示 → 新建 / 编辑。填写标题、学员化名、经历课时(节)、练习背景、变化与收获。
+- 每个案例支持 1–9 组图片:每组可以是练习前 / 后两张照片,也可以是一张已拼好的对比图;共最多 18 张原图。第一组为封面,可前后调整顺序。每张 JPG / PNG / WebP 不超过 10 MB。
+- 双图在同一画面并排展示,保留完整比例。手动滑动及左右按钮切换,提供张数、文字说明和点击放大;不自动轮播。
+- 编辑页可预览,并可把任意一组生成一张带标题、化名、课时和前后标签的对比图,通过图片预览保存。生成动作只在本地进行,不自动发布。
+- 管理员可保存草稿、发布、保存并下架、撤回授权并下架。排序数字越大越靠前,同序按创建时间和 ID 倒序。
+- 未登录访客:首页品牌区域之后,第一个内容模块展示最多 3 个已发布案例;无案例则不占位。登录用户仍优先显示快捷入口和即将开始的预约,随后展示案例。
+- 更多案例分页浏览;详情无需登录,支持分享及跳转至首页课程与体验区。下架案例的旧分享链接返回不可用状态。
+
+## 数据与权限
+
+新增 `TeachingCase` 和 `TeachingCaseAsset` 两张独立表。不关联或引用私密 `ProgressPhoto`,管理员不能利用成长档案授权直接公开照片。
+
+公开接口:
+- `GET /api/teaching-cases?page=1&limit=3`
+- `GET /api/teaching-cases/:id`
+
+管理接口均通过 JWT 及 ADMIN 角色校验:
+- `GET/POST /api/admin/teaching-cases`
+- `GET/PUT /api/admin/teaching-cases/:id`
+- `POST /api/admin/teaching-cases/:id/withdraw`
+- `POST /api/admin/teaching-cases/assets/credentials`
+- `POST /api/admin/teaching-cases/assets/:id/complete`
+
+公开查询同时限制 published 与 consentConfirmed;白名单输出案例内容,不输出授权备注、授权时间及编辑版本。公开响应使用 no-store。分页最大 20 条。
+
+图片流程为申请资产 → 上传私有 COS 对象 → HEAD 校验 → 标记完成 → 保存案例。对象固定在 `teaching-cases/` 前缀下,保存时只接受资产表中已完成的 ID,不接受外部 URL、私密成长档案对象或未完成上传。
+
+编辑版本通过条件更新递增;旧编辑页不能覆盖较新的修改或授权撤回。撤回操作同时取消发布、清除有效授权状态并增加版本;授权备注保留为管理记录,重新发布需再次确认。授权备注在请求日志中脱敏。
+
+## 授权与内容约定
+
+发布前管理员确认已取得学员单独的公开宣传授权,并记录日期、方式与凭证位置。这是线下授权记录,不是小程序替学员签署电子授权。照片、文字或用途变化后,应确认原授权覆盖新的内容。仅填写化名,不写身份证、诊断等无关信息;建议上传前遮挡面部和身份信息。系统不会自动识别面部、打码或移除 EXIF。
+
+案例展示真实个人经历,不保证相同课时获得相同效果。照片建议保持相同视角、距离和光线,避免夸大变化。
+
+## COS 与缓存边界
+
+沿用 `COS_BUCKET`、`COS_REGION`、`COS_SECRET_ID`、`COS_SECRET_KEY`,不新增环境变量。服务凭证须能上传私有对象并执行 HEAD / GET。确认桶策略没有给 teaching-cases/ 前缀额外开放匿名读取权限;对象 private ACL 不能抵消更宽泛的桶策略。
+
+- 上传签名有效 5 分钟;展示链接有效 15 分钟,重新进入页面或图片加载失败后刷新可获取新链接。
+- 下架立即阻止新查询,但已经签发的链接可能继续有效最多 15 分钟,已被下载的图片不能远程撤回。紧急移除需同时处理对应 COS 对象和任何独立缓存。
+- 微信合法域名需包含桶域名的 uploadFile / downloadFile;现有 API 域名继续用于 request。合成已有照片需要从 COS 下载,必须真机核对。
+- 中断上传及未保存编辑可能留下未引用资产。清理需先导出案例全部 frames 中的资产引用,核对后仅删除长期未引用资产,建议至少保留 7 天。不要给整个前缀配置自动过期规则,否则会删除有效案例图片。
+
+## 发布与回退
+
+按项目既有发布流程部署后端及小程序,发布前备份数据库并核对目标环境:
+
+1. 在后端环境执行 `prisma migrate deploy`,应用 `20260918000000_teaching_cases`,再生成客户端、部署后端。
+2. 验证匿名列表为空时正常返回、管理员权限、真实图片上传和读取。
+3. 发布小程序新版本,完成下方真机验收。
+
+迁移仅增表,不修改原有业务数据。应用回退时保留两表及 COS 对象,避免丢失案例和授权记录;旧前端不使用新入口。不要自动执行 DROP TABLE 或批量删除照片。
+
+## 验证
+
+已完成共享包和后端构建、Prisma schema 校验、前端 vue-tsc、微信小程序生产构建、差异空白检查。新增服务/存储/HTTP 测试覆盖权限、草稿隔离、授权条件、上传完成性、多组图、分页参数及并发编辑。全量回归的普通测试在沙箱通过,HTTP 测试需要本机回环监听权限并已单独通过;身份在 HTTP 测试中模拟,角色守卫及 DTO 校验为真实实现。
+
+仓库 `pnpm lint` 当前无法执行:server 声明了 eslint 命令,但未安装 eslint(command not found);本次未修改依赖来扩大范围。
+
+真机 / 实际存储验收仍需完成:
+- 游客有 / 无案例、登录切换及首页刷新;已登录预约模块优先级。
+- 多组双图与合成图、长标题、小屏幕、滑动/按钮翻页与全屏预览。
+- 上传取消、断网重试、超大图、缺少半组照片、保存时重复点击。
+- 真机 Canvas 合成图正确、前后图不裁切、图片预览可保存。
+- 草稿不公开、授权撤回、旧分享链接失效、旧编辑页并发冲突。
+- 同一页面停留超过 15 分钟后图片刷新;确认 COS 前缀匿名访问被拒绝。
+
+设计参考:[移动端轮播可用性](https://www.nngroup.com/articles/mobile-carousels/);[个人信息保护法](https://www.npc.gov.cn/npc/c2/c30834/202108/t20210820_313088.html)。
+
+## 2026-09-18 生产发布记录
+
+- 部署前发现生产已上线商城、当前工作区落后一个提交;已快进同步 `95b9f65` 并恢复教学案例改动,保留商城模型、接口及前端入口。没有提交或推送教学案例改动。
+- 合并后共享包/后端构建、前端类型检查通过;29 个测试套件、455 项测试全部通过。
+- 服务器本地备份:`/usr/local/backups/mp-pilates/2026-09-18T10-20-50-357Z-teaching-cases/`。包含 `database.sql.gz` 与 `backend.tar.gz`,已检查压缩包完整性;权限限制为服务器管理员访问。
+- 使用 `pnpm deploy:server` 完成上传。部署脚本补充排除整个 logs 目录,避免上传本地日志轮转索引覆盖生产索引;生产 .env 保持不变。
+- 生产数据库 `db_mp_focus` 于北京时间 18:22:18 应用 `20260918000000_teaching_cases`,新增两张表可查询,暂为 0 条记录;商城现有商品记录保留。
+- 服务器本地及公网 HTTPS:health 200、teaching-cases 200(空列表)、未登录 admin/teaching-cases 401;不存在的案例 404。商城 products 接口 200。
+- PM2 服务 `mp-pilates-server` online,部署后检查无异常重启。
+- 本次完成生产数据库迁移及后端发布;未发布微信小程序,未替代真实照片上传、Canvas 合成和真机界面验收。
diff --git a/packages/app/src/components/TeachingCaseCard.vue b/packages/app/src/components/TeachingCaseCard.vue
new file mode 100644
index 0000000..6e9287c
--- /dev/null
+++ b/packages/app/src/components/TeachingCaseCard.vue
@@ -0,0 +1,18 @@
+
+
+ {{ item.displayName }} · 练习记录{{ item.title }}{{ item.lessonCount }}节练习
+
+ {{ item.achievement }}
+
+
+
+
+
diff --git a/packages/app/src/components/TeachingCaseGallery.vue b/packages/app/src/components/TeachingCaseGallery.vue
new file mode 100644
index 0000000..525763e
--- /dev/null
+++ b/packages/app/src/components/TeachingCaseGallery.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ 练习前
+
+
+
+ {{ lessonCount }} 节后
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/components/TeachingCaseSection.vue b/packages/app/src/components/TeachingCaseSection.vue
new file mode 100644
index 0000000..f20d15c
--- /dev/null
+++ b/packages/app/src/components/TeachingCaseSection.vue
@@ -0,0 +1,17 @@
+
+
+ 练习,有迹可循看见真实的改变
+
+
+
+
+
+
diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json
index 6ff1d1d..9414e9f 100644
--- a/packages/app/src/pages.json
+++ b/packages/app/src/pages.json
@@ -80,6 +80,18 @@
"style": {
"navigationStyle": "custom"
}
+ },
+ {
+ "path": "pages/home/teaching-cases",
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/home/teaching-case",
+ "style": {
+ "navigationStyle": "custom"
+ }
}
],
"subPackages": [
@@ -218,6 +230,18 @@
"style": {
"navigationStyle": "custom"
}
+ },
+ {
+ "path": "teaching-cases",
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "teaching-case-edit",
+ "style": {
+ "navigationStyle": "custom"
+ }
}
]
},
diff --git a/packages/app/src/pages/admin/index.vue b/packages/app/src/pages/admin/index.vue
index 59e70df..ba26f06 100644
--- a/packages/app/src/pages/admin/index.vue
+++ b/packages/app/src/pages/admin/index.vue
@@ -41,6 +41,10 @@
统计分析
›
+
+ 教学案例展示
+ ›
+
课后评价
›
diff --git a/packages/app/src/pages/admin/stores/admin.ts b/packages/app/src/pages/admin/stores/admin.ts
index 6ad232d..fb04fa6 100644
--- a/packages/app/src/pages/admin/stores/admin.ts
+++ b/packages/app/src/pages/admin/stores/admin.ts
@@ -1,3 +1,4 @@
+import type { TeachingCaseAdmin, TeachingCaseInput, TeachingCasePage, TeachingCaseUpload } from '@mp-pilates/shared'
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
import { defineStore } from 'pinia'
import { ref } from 'vue'
@@ -296,7 +297,17 @@ export const useAdminStore = defineStore('admin', () => {
)
}
+ function fetchTeachingCases(page = 1) { return get>('/admin/teaching-cases', { page, limit: 10 }) }
+ function fetchTeachingCase(id: string) { return get(`/admin/teaching-cases/${id}`) }
+ function saveTeachingCase(input: TeachingCaseInput, id?: string) {
+ return id ? put(`/admin/teaching-cases/${id}`, { ...input }) : post('/admin/teaching-cases', { ...input })
+ }
+ function withdrawTeachingCase(id: string) { return post(`/admin/teaching-cases/${id}/withdraw`) }
+ function createTeachingCaseUpload(fileName: string) { return post('/admin/teaching-cases/assets/credentials', { fileName }) }
+ function completeTeachingCaseUpload(id: string) { return post<{ id: string; url: string }>(`/admin/teaching-cases/assets/${id}/complete`) }
+
return {
+ fetchTeachingCases, fetchTeachingCase, saveTeachingCase, withdrawTeachingCase, createTeachingCaseUpload, completeTeachingCaseUpload,
fetchReviews, fetchReviewTrend,
fetchTeachingAnalytics,
// State
diff --git a/packages/app/src/pages/admin/teaching-case-edit.vue b/packages/app/src/pages/admin/teaching-case-edit.vue
new file mode 100644
index 0000000..f2eee53
--- /dev/null
+++ b/packages/app/src/pages/admin/teaching-case-edit.vue
@@ -0,0 +1,132 @@
+
+
+
+ 正在读取案例…
+ {{ error }}
+
+ 记录一段真实的改变用真实照片和具体感受,让第一次来的学员了解你的教学。
+
+ 案例标题
+ 学员称呼 · 建议化名经历课时 · 节
+ 练习的起点 · 选填
+ 变化与收获
+
+ 对比照片{{ form.frames.length }} / 9 组
+ 第一组作为封面。建议前后照片采用相同角度、距离与光线,上传前遮挡面部及身份信息。每张最多 10 MB。
+
+ {{ index === 0 ? '封面' : '第 ' + (index + 1) + ' 组' }} · {{ frame.kind === 'pair' ? '前后双图' : '已拼好的对比图' }}
+
+
+
+
+
+
+
+
+
+ 首页排序数字越大越靠前;相同数字按新建时间排序。
+ 已取得学员单独同意,将本案例照片及文字用于小程序公开宣传
+ 授权记录 · 仅管理员可见
+ 私密成长档案的授权不能替代公开宣传授权。修改照片或用途后,请再次确认授权范围。
+
+
+ {{ form.title || '案例标题' }}{{ form.displayName || '学员化名' }} · {{ lessonCount || '—' }} 节课{{ form.achievement }}
+
+ 正在处理图片,请稍候…
+
+
+
+
+
+
diff --git a/packages/app/src/pages/admin/teaching-cases.vue b/packages/app/src/pages/admin/teaching-cases.vue
new file mode 100644
index 0000000..33c729c
--- /dev/null
+++ b/packages/app/src/pages/admin/teaching-cases.vue
@@ -0,0 +1,53 @@
+
+
+
+
+ 让进步被看见。发布后的案例会优先展示在游客首页。使用化名,上传前请取得学员单独的公开宣传授权。
+
+ {{ error }}
+
+
+ {{ row.published ? '已发布' : '草稿 / 已下架' }}排序 {{ row.sortOrder }} · {{ row.frames.length }} 组图片
+ {{ row.title }}{{ row.displayName }} · {{ row.lessonCount }} 节课
+
+
+ 正在加载…还没有教学案例从一个真实的练习故事开始,保存为草稿后再发布。
+
+
+
+
+
+
diff --git a/packages/app/src/pages/home/index.vue b/packages/app/src/pages/home/index.vue
index 7d4ae54..7d61303 100644
--- a/packages/app/src/pages/home/index.vue
+++ b/packages/app/src/pages/home/index.vue
@@ -9,8 +9,10 @@
+
+
@@ -28,6 +30,9 @@
import { ref, nextTick, onUnmounted } from 'vue'
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
+import type { TeachingCasePage, TeachingCasePublic } from '@mp-pilates/shared'
+import { get } from '../../utils/request'
+import TeachingCaseSection from '../../components/TeachingCaseSection.vue'
import BrandBanner from '../../components/BrandBanner.vue'
import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
import StudioInfo from '../../components/StudioInfo.vue'
@@ -44,6 +49,15 @@ import { useBookingStore } from '../../stores/booking'
const userStore = useUserStore()
const studioStore = useStudioStore()
const bookingStore = useBookingStore()
+const teachingCases = ref([])
+let caseRequest = 0
+async function fetchTeachingCases() {
+ const request = ++caseRequest
+ try {
+ const result = await get('/teaching-cases', { page: 1, limit: 3 })
+ if (request === caseRequest) teachingCases.value = result.items
+ } catch { if (request === caseRequest) teachingCases.value = [] }
+}
// ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => {
@@ -82,6 +96,10 @@ onUnmounted(() => {
// Refresh all data on every show
onShow(async () => {
+ if (uni.getStorageSync('home:card-shop-requested')) {
+ uni.removeStorageSync('home:card-shop-requested')
+ pendingScrollToCardShop.value = true
+ }
await refreshData()
// If another page requested scroll to card shop, execute after data is ready
@@ -93,7 +111,7 @@ onShow(async () => {
})
async function refreshData() {
- const tasks: Promise[] = [studioStore.fetchStudioInfo()]
+ const tasks: Promise[] = [studioStore.fetchStudioInfo(), fetchTeachingCases()]
if (userStore.loggedIn) {
tasks.push(
diff --git a/packages/app/src/pages/home/teaching-case.vue b/packages/app/src/pages/home/teaching-case.vue
new file mode 100644
index 0000000..9728d0b
--- /dev/null
+++ b/packages/app/src/pages/home/teaching-case.vue
@@ -0,0 +1,50 @@
+
+
+
+
+ 正在读取案例…
+ {{ error }}
+
+ {{ item.displayName }} · 真实练习记录{{ item.title }}
+ {{ item.lessonCount }}节课,记录这一段改变
+
+ 练习的起点{{ item.background }}
+ 看得见,也感受得到{{ item.achievement }}
+ 经学员单独授权公开分享。每个人的基础、练习频率与感受不同,案例不代表相同课时下的效果承诺。
+ 你的练习,从了解自己开始。找到适合自己的节奏,和老师一起开始。
+
+
+
+
+
+
+
diff --git a/packages/app/src/pages/home/teaching-cases.vue b/packages/app/src/pages/home/teaching-cases.vue
new file mode 100644
index 0000000..4f6f583
--- /dev/null
+++ b/packages/app/src/pages/home/teaching-cases.vue
@@ -0,0 +1,39 @@
+
+
+
+ 练习,有迹可循改变,从一次次练习开始。每个人的起点不同,值得被看见的进步也不同。
+ {{ error }}
+
+ 正在读取案例…案例正在整理中,期待与你分享。
+
+
+
+
+
+
+
diff --git a/packages/server/prisma/migrations/20260918000000_teaching_cases/migration.sql b/packages/server/prisma/migrations/20260918000000_teaching_cases/migration.sql
new file mode 100644
index 0000000..25c1276
--- /dev/null
+++ b/packages/server/prisma/migrations/20260918000000_teaching_cases/migration.sql
@@ -0,0 +1,27 @@
+CREATE TABLE `TeachingCase` (
+ `id` VARCHAR(191) NOT NULL,
+ `title` VARCHAR(80) NOT NULL,
+ `displayName` VARCHAR(30) NOT NULL,
+ `lessonCount` INTEGER NOT NULL,
+ `background` VARCHAR(500) NOT NULL,
+ `achievement` VARCHAR(1000) NOT NULL,
+ `frames` JSON NOT NULL,
+ `sortOrder` INTEGER NOT NULL DEFAULT 0,
+ `published` BOOLEAN NOT NULL DEFAULT false,
+ `consentConfirmed` BOOLEAN NOT NULL DEFAULT false,
+ `consentNote` VARCHAR(500) NOT NULL,
+ `consentedAt` DATETIME(3) NULL,
+ `version` INTEGER NOT NULL DEFAULT 1,
+ `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updatedAt` DATETIME(3) NOT NULL,
+ INDEX `TeachingCase_published_sortOrder_createdAt_idx` (`published`, `sortOrder`, `createdAt`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+CREATE TABLE `TeachingCaseAsset` (
+ `id` VARCHAR(191) NOT NULL,
+ `objectKey` VARCHAR(255) NOT NULL,
+ `ready` BOOLEAN NOT NULL DEFAULT false,
+ `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE INDEX `TeachingCaseAsset_objectKey_key` (`objectKey`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index c3a90f1..cad8dbf 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -691,3 +691,31 @@ model ProductStockChange {
@@index([productId, createdAt])
@@map("product_stock_changes")
}
+
+// Public teaching stories never reference private ProgressPhoto records.
+model TeachingCase {
+ id String @id @default(cuid())
+ title String @db.VarChar(80)
+ displayName String @db.VarChar(30)
+ lessonCount Int
+ background String @db.VarChar(500)
+ achievement String @db.VarChar(1000)
+ frames Json
+ sortOrder Int @default(0)
+ published Boolean @default(false)
+ consentConfirmed Boolean @default(false)
+ consentNote String @db.VarChar(500)
+ consentedAt DateTime?
+ version Int @default(1)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([published, sortOrder, createdAt])
+}
+
+model TeachingCaseAsset {
+ id String @id @default(cuid())
+ objectKey String @unique @db.VarChar(255)
+ ready Boolean @default(false)
+ createdAt DateTime @default(now())
+}
diff --git a/packages/server/src/common/utils/request-log.ts b/packages/server/src/common/utils/request-log.ts
index 71c01a2..3a79599 100644
--- a/packages/server/src/common/utils/request-log.ts
+++ b/packages/server/src/common/utils/request-log.ts
@@ -9,6 +9,7 @@ const SENSITIVE_FIELDS: ReadonlySet = new Set([
'sessionKey',
'encryptedData',
'iv',
+ 'consentNote',
])
const BODY_METHODS: ReadonlySet = new Set(['POST', 'PUT', 'PATCH'])
diff --git a/packages/server/src/studio/__tests__/teaching-case-storage.service.spec.ts b/packages/server/src/studio/__tests__/teaching-case-storage.service.spec.ts
new file mode 100644
index 0000000..433b359
--- /dev/null
+++ b/packages/server/src/studio/__tests__/teaching-case-storage.service.spec.ts
@@ -0,0 +1,23 @@
+import { ConfigService } from '@nestjs/config'
+import { TeachingCaseStorageService } from '../teaching-case-storage.service'
+
+describe('Teaching case private storage', () => {
+ const values: Record = { COS_BUCKET: 'test-123', COS_REGION: 'ap-guangzhou', COS_SECRET_ID: 'test-id', COS_SECRET_KEY: 'test-key' }
+ const service = new TeachingCaseStorageService({ get: (key: string) => values[key] } as ConfigService)
+ it('isolates public-case assets from studio gallery and private member archives', () => {
+ const result = service.createCaseCredential('photo.PNG')
+ expect(result.key).toMatch(/^teaching-cases\/[a-f0-9-]+\.png$/)
+ expect(result.formData['x-cos-acl']).toBe('private')
+ const policy = JSON.parse(Buffer.from(result.formData.policy, 'base64').toString())
+ expect(policy.conditions).toContainEqual({ 'x-cos-acl': 'private' })
+ expect(policy.conditions).toContainEqual(['eq', '$key', result.key])
+ expect(policy.conditions).toContainEqual(['content-length-range', 0, 10485760])
+ })
+ it.each(['file.svg', 'file.pdf', 'photo.heic'])('rejects unsupported public image formats: %s', file => { expect(() => service.createCaseCredential(file)).toThrow() })
+ it('uses bounded signed links for public displays', () => {
+ const url = new URL(service.signedUrl('teaching-cases/photo.jpg', 'get', 900))
+ const [start, end] = url.searchParams.get('q-sign-time')!.split(';').map(Number)
+ expect(end - start).toBe(905)
+ expect(url.searchParams.get('q-signature')).toBeTruthy()
+ })
+})
diff --git a/packages/server/src/studio/__tests__/teaching-case.controller.spec.ts b/packages/server/src/studio/__tests__/teaching-case.controller.spec.ts
new file mode 100644
index 0000000..673194a
--- /dev/null
+++ b/packages/server/src/studio/__tests__/teaching-case.controller.spec.ts
@@ -0,0 +1,58 @@
+import { INestApplication, UnauthorizedException, ValidationPipe } from '@nestjs/common'
+import { Test } from '@nestjs/testing'
+import type { ExecutionContext } from '@nestjs/common'
+import type { Request } from 'express'
+import type { AddressInfo } from 'node:net'
+import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
+import { RolesGuard } from '../../auth/roles.guard'
+import { TeachingCaseAdminController, TeachingCasePublicController } from '../teaching-case.controller'
+import { TeachingCaseService } from '../teaching-case.service'
+
+// Authentication is simulated; the actual RolesGuard and global DTO validation run here.
+describe('Teaching cases HTTP roles and validation', () => {
+ let app: INestApplication
+ // Native fetch keeps the HTTP test independent of optional supertest typings.
+ function request(_server: unknown) {
+ function call(method: string, path: string) {
+ const headers: Record = { 'Content-Type': 'application/json' }
+ let body: string | undefined
+ const chain = {
+ set(key: string, value: string) { headers[key] = value; return chain },
+ send(value: unknown) { body = JSON.stringify(value); return chain },
+ async expect(status: number) {
+ const address = app.getHttpServer().address() as AddressInfo
+ const response = await fetch(`http://127.0.0.1:${address.port}${path}`, { method, headers, body })
+ await response.text()
+ expect(response.status).toBe(status)
+ },
+ }
+ return chain
+ }
+ return { get: (path: string) => call('GET', path), post: (path: string) => call('POST', path), put: (path: string) => call('PUT', path) }
+ }
+ const service = { list: jest.fn().mockResolvedValue({ items: [], total: 0 }), detail: jest.fn().mockResolvedValue({}), save: jest.fn().mockResolvedValue({}), upload: jest.fn().mockResolvedValue({}), completeUpload: jest.fn().mockResolvedValue({}), withdraw: jest.fn().mockResolvedValue({}) }
+ const valid = { title: '案例', displayName: '化名', lessonCount: 12, background: '', achievement: '收获', frames: [{ kind: 'composite', beforeId: 'asset-id', caption: '' }], sortOrder: 0, published: false, consentConfirmed: false, consentNote: '', version: 0 }
+ beforeAll(async () => {
+ const module = await Test.createTestingModule({ controllers: [TeachingCasePublicController, TeachingCaseAdminController], providers: [{ provide: TeachingCaseService, useValue: service }, RolesGuard] })
+ .overrideGuard(JwtAuthGuard).useValue({ canActivate(context: ExecutionContext) { const req = context.switchToHttp().getRequest(); if (!req.headers['x-test-role']) throw new UnauthorizedException(); req.user = { role: String(req.headers['x-test-role']) }; return true } }).compile()
+ app = module.createNestApplication()
+ app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
+ await app.listen(0, '127.0.0.1')
+ })
+ afterAll(async () => { await app.close() })
+ it('lets visitors list cases without authentication', async () => { await request(app.getHttpServer()).get('/teaching-cases?limit=3').expect(200); expect(service.list).toHaveBeenCalledWith(expect.objectContaining({ page: 1, limit: 3 })) })
+ it.each(['', 'MEMBER'])('prevents writes and admin reads by role %s', async role => {
+ const code = role ? 403 : 401
+ await request(app.getHttpServer()).get('/admin/teaching-cases').set('x-test-role', role).expect(code)
+ await request(app.getHttpServer()).post('/admin/teaching-cases').set('x-test-role', role).send(valid).expect(code)
+ await request(app.getHttpServer()).post('/admin/teaching-cases/assets/credentials').set('x-test-role', role).send({ fileName: 'a.jpg' }).expect(code)
+ await request(app.getHttpServer()).post('/admin/teaching-cases/assets/a/complete').set('x-test-role', role).expect(code)
+ await request(app.getHttpServer()).put('/admin/teaching-cases/a').set('x-test-role', role).send(valid).expect(code)
+ await request(app.getHttpServer()).post('/admin/teaching-cases/a/withdraw').set('x-test-role', role).expect(code)
+ })
+ it('accepts a valid administrator save', async () => { await request(app.getHttpServer()).post('/admin/teaching-cases').set('x-test-role', 'ADMIN').send(valid).expect(201) })
+ it.each([{ lessonCount: 0 }, { lessonCount: 1.5 }, { lessonCount: '12' }, { title: ' ' }, { frames: [] }, { frames: Array(10).fill(valid.frames[0]) }, { frames: [{ ...valid.frames[0], kind: 'other' }] }, { frames: [{ ...valid.frames[0], beforeUrl: 'https://example.com' }] }, { consentConfirmed: 'true' }, { version: -1 }])('rejects invalid payload %j', async patch => {
+ await request(app.getHttpServer()).post('/admin/teaching-cases').set('x-test-role', 'ADMIN').send({ ...valid, ...patch }).expect(400)
+ })
+ it.each(['page=0', 'limit=1000', 'page=abc', 'limit=-1'])('bounds anonymous pagination: %s', async query => { await request(app.getHttpServer()).get(`/teaching-cases?${query}`).expect(400) })
+})
diff --git a/packages/server/src/studio/__tests__/teaching-case.service.spec.ts b/packages/server/src/studio/__tests__/teaching-case.service.spec.ts
new file mode 100644
index 0000000..3eb8ea2
--- /dev/null
+++ b/packages/server/src/studio/__tests__/teaching-case.service.spec.ts
@@ -0,0 +1,110 @@
+import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'
+import { PrismaService } from '../../prisma/prisma.service'
+import { TeachingCaseService } from '../teaching-case.service'
+import { TeachingCaseStorageService } from '../teaching-case-storage.service'
+import { SaveTeachingCaseDto } from '../dto/teaching-case.dto'
+
+const input = (): SaveTeachingCaseDto => ({ title: '舒展的变化', displayName: '小林', lessonCount: 24, background: '久坐', achievement: '站姿更舒展', frames: [{ kind: 'pair', beforeId: 'before', afterId: 'after', caption: '侧面' }], sortOrder: 5, published: true, consentConfirmed: true, consentNote: '2026-09-18 书面授权', version: 1 })
+const record = () => ({ ...input(), id: 'case-1', createdAt: new Date(), updatedAt: new Date(), consentedAt: new Date() })
+
+describe('TeachingCaseService publication and storage boundaries', () => {
+ const prisma = {
+ teachingCase: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn(), create: jest.fn(), updateMany: jest.fn() },
+ teachingCaseAsset: { count: jest.fn(), findMany: jest.fn(), create: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
+ $transaction: jest.fn(),
+ }
+ const storage = { signedUrl: jest.fn(), createCaseCredential: jest.fn(), verify: jest.fn() }
+ let service: TeachingCaseService
+ beforeEach(() => {
+ jest.resetAllMocks()
+ prisma.$transaction.mockImplementation((queries: Promise[]) => Promise.all(queries))
+ prisma.teachingCase.findMany.mockResolvedValue([record()])
+ prisma.teachingCase.count.mockResolvedValue(1)
+ prisma.teachingCase.findFirst.mockResolvedValue(record())
+ prisma.teachingCase.findUnique.mockResolvedValue(record())
+ prisma.teachingCase.updateMany.mockResolvedValue({ count: 1 })
+ prisma.teachingCaseAsset.count.mockResolvedValue(2)
+ prisma.teachingCaseAsset.findMany.mockResolvedValue([{ id: 'before', objectKey: 'teaching-cases/before.jpg' }, { id: 'after', objectKey: 'teaching-cases/after.jpg' }])
+ storage.signedUrl.mockImplementation((key: string) => `signed:${key}`)
+ service = new TeachingCaseService(prisma as unknown as PrismaService, storage as unknown as TeachingCaseStorageService)
+ })
+
+ it('only lists published consented cases in deterministic order and excludes private consent fields', async () => {
+ const result = await service.list({ page: 2, limit: 3 })
+ expect(prisma.teachingCase.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { published: true, consentConfirmed: true }, skip: 3, take: 3, orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }] }))
+ expect(prisma.teachingCase.count).toHaveBeenCalledWith({ where: { published: true, consentConfirmed: true } })
+ expect(result.items[0]).not.toHaveProperty('consentNote')
+ expect(result.items[0]).not.toHaveProperty('consentedAt')
+ expect(result.items[0]).not.toHaveProperty('version')
+ expect(result.items[0].frames[0]).toMatchObject({ beforeUrl: 'signed:teaching-cases/before.jpg', afterUrl: 'signed:teaching-cases/after.jpg' })
+ expect(storage.signedUrl).toHaveBeenCalledWith('teaching-cases/before.jpg', 'get', 900)
+ })
+
+ it('does not reveal a draft or withdrawn case through a direct public URL', async () => {
+ prisma.teachingCase.findFirst.mockResolvedValue(null)
+ await expect(service.detail('draft-id')).rejects.toBeInstanceOf(NotFoundException)
+ expect(prisma.teachingCase.findFirst).toHaveBeenCalledWith({ where: { id: 'draft-id', published: true, consentConfirmed: true } })
+ expect(storage.signedUrl).not.toHaveBeenCalled()
+ })
+
+ it('returns private consent details only in the admin view', async () => {
+ const result = await service.list({ page: 1, limit: 10 }, true)
+ expect(prisma.teachingCase.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: {} }))
+ expect(result.items[0]).toHaveProperty('consentNote', input().consentNote)
+ })
+
+ it.each([{ consentConfirmed: false }, { consentNote: ' ' }])('requires separate documented consent before publishing: %j', async patch => {
+ await expect(service.save({ ...input(), ...patch })).rejects.toBeInstanceOf(BadRequestException)
+ expect(prisma.teachingCase.create).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ [{ kind: 'pair' as const, beforeId: 'before', caption: '' }],
+ [{ kind: 'pair' as const, beforeId: 'before', afterId: 'before', caption: '' }],
+ [{ kind: 'composite' as const, beforeId: 'before', afterId: 'after', caption: '' }],
+ ])('rejects incomplete or ambiguous comparison frames', async frame => {
+ await expect(service.save({ ...input(), frames: [frame] })).rejects.toBeInstanceOf(BadRequestException)
+ })
+
+ it('rejects fabricated asset IDs, private archive photos and incomplete uploads', async () => {
+ prisma.teachingCaseAsset.count.mockResolvedValue(1)
+ await expect(service.save(input())).rejects.toThrow('未上传完成')
+ expect(prisma.teachingCaseAsset.count).toHaveBeenCalledWith({ where: { id: { in: ['before', 'after'] }, ready: true } })
+ expect(prisma.teachingCase.create).not.toHaveBeenCalled()
+ })
+
+ it('allows unpublished drafts without consent and strips presentation URLs from persisted frames', async () => {
+ prisma.teachingCase.create.mockResolvedValue(record())
+ const dto = { ...input(), published: false, consentConfirmed: false, consentNote: '', frames: [{ ...input().frames[0], beforeUrl: 'https://untrusted.test/photo.jpg' }] }
+ await service.save(dto)
+ expect(prisma.teachingCase.create).toHaveBeenCalledWith({ data: expect.objectContaining({ published: false, consentedAt: null, frames: input().frames }) })
+ })
+
+ it('preserves frame ordering and supports multiple composite photos', async () => {
+ prisma.teachingCase.create.mockResolvedValue(record())
+ const frames = [{ kind: 'composite' as const, beforeId: 'after', caption: '封面' }, { kind: 'composite' as const, beforeId: 'before', caption: '第二组' }]
+ await service.save({ ...input(), frames })
+ expect(prisma.teachingCase.create).toHaveBeenCalledWith({ data: expect.objectContaining({ frames }) })
+ })
+
+ it('rejects stale updates instead of overwriting another administrator or withdrawal', async () => {
+ prisma.teachingCase.updateMany.mockResolvedValue({ count: 0 })
+ await expect(service.save(input(), 'case-1')).rejects.toBeInstanceOf(ConflictException)
+ expect(prisma.teachingCase.updateMany).toHaveBeenCalledWith({ where: { id: 'case-1', version: 1 }, data: expect.objectContaining({ version: { increment: 1 } }) })
+ })
+
+ it('withdraws consent and publication together and invalidates older editors', async () => {
+ await service.withdraw('case-1')
+ expect(prisma.teachingCase.updateMany).toHaveBeenCalledWith({ where: { id: 'case-1' }, data: { published: false, consentConfirmed: false, consentedAt: null, version: { increment: 1 } } })
+ })
+
+ it('does not complete an upload before COS confirms the object', async () => {
+ prisma.teachingCaseAsset.findUnique.mockResolvedValue({ id: 'asset', objectKey: 'teaching-cases/a.jpg' })
+ storage.verify.mockRejectedValue(new Error('not uploaded'))
+ await expect(service.completeUpload('asset')).rejects.toThrow('not uploaded')
+ expect(prisma.teachingCaseAsset.update).not.toHaveBeenCalled()
+ storage.verify.mockResolvedValue(undefined)
+ await service.completeUpload('asset')
+ expect(prisma.teachingCaseAsset.update).toHaveBeenCalledWith({ where: { id: 'asset' }, data: { ready: true } })
+ })
+})
diff --git a/packages/server/src/studio/dto/teaching-case.dto.ts b/packages/server/src/studio/dto/teaching-case.dto.ts
new file mode 100644
index 0000000..d3955f5
--- /dev/null
+++ b/packages/server/src/studio/dto/teaching-case.dto.ts
@@ -0,0 +1,31 @@
+import { Transform, Type } from 'class-transformer'
+import { ArrayMaxSize, ArrayMinSize, IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, Length, Max, MaxLength, Min, ValidateNested } from 'class-validator'
+const Trim = () => Transform(({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value)
+
+export class TeachingCaseFrameDto {
+ @IsIn(['pair', 'composite']) kind!: 'pair' | 'composite'
+ @IsString() @Length(1, 100) beforeId!: string
+ @IsOptional() @IsString() @Length(1, 100) afterId?: string
+ @Trim() @IsString() @MaxLength(100) caption!: string
+}
+export class SaveTeachingCaseDto {
+ @Trim() @IsString() @Length(1, 80) title!: string
+ @Trim() @IsString() @Length(1, 30) displayName!: string
+ @IsInt() @Min(1) @Max(9999) lessonCount!: number
+ @Trim() @IsString() @MaxLength(500) background!: string
+ @Trim() @IsString() @Length(1, 1000) achievement!: string
+ @IsArray() @ArrayMinSize(1) @ArrayMaxSize(9)
+ @ValidateNested({ each: true }) @Type(() => TeachingCaseFrameDto) frames!: TeachingCaseFrameDto[]
+ @IsInt() @Min(0) @Max(9999) sortOrder!: number
+ @IsBoolean() published!: boolean
+ @IsBoolean() consentConfirmed!: boolean
+ @Trim() @IsString() @MaxLength(500) consentNote!: string
+ @IsInt() @Min(0) version!: number
+}
+export class TeachingCaseQueryDto {
+ @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100000) page = 1
+ @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(20) limit = 10
+}
+export class TeachingCaseUploadDto {
+ @IsString() @Length(1, 200) fileName!: string
+}
diff --git a/packages/server/src/studio/studio.module.ts b/packages/server/src/studio/studio.module.ts
index 461d92f..5099276 100644
--- a/packages/server/src/studio/studio.module.ts
+++ b/packages/server/src/studio/studio.module.ts
@@ -1,11 +1,14 @@
+import { TeachingCasePublicController, TeachingCaseAdminController } from './teaching-case.controller'
+import { TeachingCaseService } from './teaching-case.service'
+import { TeachingCaseStorageService } from './teaching-case-storage.service'
import { Module } from '@nestjs/common'
import { StudioController } from './studio.controller'
import { StudioService } from './studio.service'
import { StudioUploadService } from './studio-upload.service'
@Module({
- controllers: [StudioController],
- providers: [StudioService, StudioUploadService],
+ controllers: [StudioController, TeachingCasePublicController, TeachingCaseAdminController],
+ providers: [StudioService, StudioUploadService, TeachingCaseService, TeachingCaseStorageService],
exports: [StudioService],
})
export class StudioModule {}
diff --git a/packages/server/src/studio/teaching-case-storage.service.ts b/packages/server/src/studio/teaching-case-storage.service.ts
new file mode 100644
index 0000000..5db052d
--- /dev/null
+++ b/packages/server/src/studio/teaching-case-storage.service.ts
@@ -0,0 +1,17 @@
+import { BadRequestException, Injectable } from '@nestjs/common'
+import { ConfigService } from '@nestjs/config'
+import { randomUUID } from 'crypto'
+import { ProgressPhotoStorageService } from '../user/progress-photo-storage.service'
+
+@Injectable()
+export class TeachingCaseStorageService extends ProgressPhotoStorageService {
+ constructor(config: ConfigService) { super(config) }
+ createCaseCredential(fileName: string) {
+ const extension = this.resolveExtension(fileName)
+ if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) throw new BadRequestException('请使用 JPG、PNG 或 WebP 图片')
+ const bucket = this.getRequiredConfig('COS_BUCKET')
+ const region = this.getRequiredConfig('COS_REGION')
+ const key = `teaching-cases/${randomUUID()}.${extension}`
+ return { key, uploadUrl: `https://${bucket}.cos.${region}.myqcloud.com`, formData: this.buildPostPolicy({ bucket, key, expiresAt: Math.floor(Date.now() / 1000) + 300, privateRead: true }) }
+ }
+}
diff --git a/packages/server/src/studio/teaching-case.controller.ts b/packages/server/src/studio/teaching-case.controller.ts
new file mode 100644
index 0000000..d01a4f1
--- /dev/null
+++ b/packages/server/src/studio/teaching-case.controller.ts
@@ -0,0 +1,29 @@
+import { Body, Controller, Get, Header, Param, Post, Put, Query, 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 { SaveTeachingCaseDto, TeachingCaseQueryDto, TeachingCaseUploadDto } from './dto/teaching-case.dto'
+import { TeachingCaseService } from './teaching-case.service'
+
+@Controller('teaching-cases')
+export class TeachingCasePublicController {
+ constructor(private readonly service: TeachingCaseService) {}
+ @Get() @Header('Cache-Control', 'no-store')
+ list(@Query() query: TeachingCaseQueryDto) { return this.service.list(query) }
+ @Get(':id') @Header('Cache-Control', 'no-store')
+ detail(@Param('id') id: string) { return this.service.detail(id) }
+}
+@Controller('admin/teaching-cases')
+@UseGuards(JwtAuthGuard, RolesGuard)
+@Roles(UserRole.ADMIN)
+export class TeachingCaseAdminController {
+ constructor(private readonly service: TeachingCaseService) {}
+ @Get() list(@Query() query: TeachingCaseQueryDto) { return this.service.list(query, true) }
+ @Get(':id') detail(@Param('id') id: string) { return this.service.detail(id, true) }
+ @Post() create(@Body() dto: SaveTeachingCaseDto) { return this.service.save(dto) }
+ @Put(':id') update(@Param('id') id: string, @Body() dto: SaveTeachingCaseDto) { return this.service.save(dto, id) }
+ @Post(':id/withdraw') withdraw(@Param('id') id: string) { return this.service.withdraw(id) }
+ @Post('assets/credentials') upload(@Body() dto: TeachingCaseUploadDto) { return this.service.upload(dto.fileName) }
+ @Post('assets/:id/complete') complete(@Param('id') id: string) { return this.service.completeUpload(id) }
+}
diff --git a/packages/server/src/studio/teaching-case.service.ts b/packages/server/src/studio/teaching-case.service.ts
new file mode 100644
index 0000000..b12bdee
--- /dev/null
+++ b/packages/server/src/studio/teaching-case.service.ts
@@ -0,0 +1,82 @@
+import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
+import type { TeachingCase } from '@prisma/client'
+import type { TeachingCaseFrame } from '@mp-pilates/shared'
+import { PrismaService } from '../prisma/prisma.service'
+import { SaveTeachingCaseDto, TeachingCaseQueryDto } from './dto/teaching-case.dto'
+import { TeachingCaseStorageService } from './teaching-case-storage.service'
+
+@Injectable()
+export class TeachingCaseService {
+ constructor(private readonly prisma: PrismaService, private readonly storage: TeachingCaseStorageService) {}
+
+ async list(query: TeachingCaseQueryDto, admin = false) {
+ const where = admin ? {} : { published: true, consentConfirmed: true }
+ const [rows, total] = await this.prisma.$transaction([
+ this.prisma.teachingCase.findMany({ where, orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], skip: (query.page - 1) * query.limit, take: query.limit }),
+ this.prisma.teachingCase.count({ where }),
+ ])
+ return { items: await this.present(rows, admin), total, page: query.page, limit: query.limit }
+ }
+
+ async detail(id: string, admin = false) {
+ const row = await this.prisma.teachingCase.findFirst({ where: { id, ...(admin ? {} : { published: true, consentConfirmed: true }) } })
+ if (!row) throw new NotFoundException('案例不存在或已下架')
+ return (await this.present([row], admin))[0]
+ }
+
+ async save(dto: SaveTeachingCaseDto, id?: string) {
+ if (dto.published && (!dto.consentConfirmed || !dto.consentNote.trim())) throw new BadRequestException('发布前请确认公开授权,并填写授权记录')
+ if (dto.consentConfirmed && !dto.consentNote.trim()) throw new BadRequestException('请填写授权记录')
+ const frames = dto.frames.map(frame => {
+ if (frame.kind === 'pair' && (!frame.afterId || frame.afterId === frame.beforeId)) throw new BadRequestException('请上传不同的练习前、后照片')
+ if (frame.kind === 'composite' && frame.afterId) throw new BadRequestException('合成图只需一张照片')
+ return { kind: frame.kind, beforeId: frame.beforeId, ...(frame.afterId ? { afterId: frame.afterId } : {}), caption: frame.caption }
+ })
+ const assetIds = [...new Set(frames.flatMap(frame => [frame.beforeId, ...(frame.afterId ? [frame.afterId] : [])]))]
+ const count = await this.prisma.teachingCaseAsset.count({ where: { id: { in: assetIds }, ready: true } })
+ if (count !== assetIds.length) throw new BadRequestException('存在未上传完成的照片,请重新上传')
+ const previous = id ? await this.prisma.teachingCase.findUnique({ where: { id } }) : null
+ if (id && !previous) throw new NotFoundException('案例不存在')
+ const { version, ...input } = dto
+ const data = { ...input, frames, consentedAt: dto.consentConfirmed ? (previous?.consentConfirmed && previous.consentNote === dto.consentNote ? previous.consentedAt : new Date()) : null }
+ if (!id) {
+ const row = await this.prisma.teachingCase.create({ data })
+ return this.detail(row.id, true)
+ }
+ const updated = await this.prisma.teachingCase.updateMany({ where: { id, version }, data: { ...data, version: { increment: 1 } } })
+ if (!updated.count) throw new ConflictException('案例已被修改,请重新打开后编辑')
+ return this.detail(id, true)
+ }
+
+ async withdraw(id: string) {
+ const result = await this.prisma.teachingCase.updateMany({ where: { id }, data: { published: false, consentConfirmed: false, consentedAt: null, version: { increment: 1 } } })
+ if (!result.count) throw new NotFoundException('案例不存在')
+ return { success: true }
+ }
+
+ async upload(fileName: string) {
+ const credential = this.storage.createCaseCredential(fileName)
+ const asset = await this.prisma.teachingCaseAsset.create({ data: { objectKey: credential.key } })
+ return { id: asset.id, uploadUrl: credential.uploadUrl, formData: credential.formData }
+ }
+
+ async completeUpload(id: string) {
+ const asset = await this.prisma.teachingCaseAsset.findUnique({ where: { id } })
+ if (!asset) throw new NotFoundException('图片不存在')
+ await this.storage.verify(asset.objectKey)
+ await this.prisma.teachingCaseAsset.update({ where: { id }, data: { ready: true } })
+ return { id, url: this.storage.signedUrl(asset.objectKey, 'get', 900) }
+ }
+
+ private async present(rows: TeachingCase[], admin: boolean) {
+ const framesOf = (row: TeachingCase) => row.frames as unknown as TeachingCaseFrame[]
+ const ids = rows.flatMap(row => framesOf(row).flatMap(frame => [frame.beforeId, ...(frame.afterId ? [frame.afterId] : [])]))
+ const assets = await this.prisma.teachingCaseAsset.findMany({ where: { id: { in: ids }, ready: true } })
+ const urls = new Map(assets.map(asset => [asset.id, this.storage.signedUrl(asset.objectKey, 'get', 900)]))
+ return rows.map(row => ({
+ id: row.id, title: row.title, displayName: row.displayName, lessonCount: row.lessonCount, background: row.background, achievement: row.achievement,
+ frames: framesOf(row).map(frame => ({ ...frame, beforeUrl: urls.get(frame.beforeId), ...(frame.afterId ? { afterUrl: urls.get(frame.afterId) } : {}) })),
+ ...(admin ? { sortOrder: row.sortOrder, published: row.published, consentConfirmed: row.consentConfirmed, consentNote: row.consentNote, consentedAt: row.consentedAt, version: row.version } : {}),
+ }))
+ }
+}
diff --git a/packages/server/src/user/progress-photo-storage.service.ts b/packages/server/src/user/progress-photo-storage.service.ts
index ee306d6..97121c0 100644
--- a/packages/server/src/user/progress-photo-storage.service.ts
+++ b/packages/server/src/user/progress-photo-storage.service.ts
@@ -17,9 +17,9 @@ export class ProgressPhotoStorageService extends StudioUploadService {
const expiresAt = Math.floor(Date.now() / 1000) + 300
return { key, uploadUrl: `https://${host}`, expiresAt, formData: this.buildPostPolicy({ bucket, key, expiresAt, privateRead: true }) }
}
- signedUrl(key: string, method = 'get') {
+ signedUrl(key: string, method = 'get', durationSeconds = 60) {
const { host } = this.endpoint()
- const keyTime = `${Math.floor(Date.now() / 1000) - 5};${Math.floor(Date.now() / 1000) + 60}`
+ const keyTime = `${Math.floor(Date.now() / 1000) - 5};${Math.floor(Date.now() / 1000) + durationSeconds}`
const signKey = createHmac('sha1', this.getRequiredConfig('COS_SECRET_KEY')).update(keyTime).digest('hex')
const http = `${method}\n/${key}\n\nhost=${encodeURIComponent(host)}\n`
const toSign = `sha1\n${keyTime}\n${createHash('sha1').update(http).digest('hex')}\n`
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index a9fe050..5abb097 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -140,3 +140,5 @@ export * from './types/member-care'
export * from './types/body-portrait'
export * from './types/shop'
+
+export * from './types/teaching-case'
diff --git a/packages/shared/src/types/teaching-case.ts b/packages/shared/src/types/teaching-case.ts
new file mode 100644
index 0000000..91272e8
--- /dev/null
+++ b/packages/shared/src/types/teaching-case.ts
@@ -0,0 +1,36 @@
+/** Public teaching stories are separate from private member progress. */
+export interface TeachingCaseFrame {
+ kind: 'pair' | 'composite'
+ beforeId: string
+ afterId?: string
+ caption: string
+ beforeUrl?: string
+ afterUrl?: string
+}
+export interface TeachingCaseInput {
+ title: string
+ displayName: string
+ lessonCount: number
+ background: string
+ achievement: string
+ frames: TeachingCaseFrame[]
+ sortOrder: number
+ published: boolean
+ consentConfirmed: boolean
+ consentNote: string
+ version: number
+}
+export interface TeachingCasePublic {
+ id: string
+ title: string
+ displayName: string
+ lessonCount: number
+ background: string
+ achievement: string
+ frames: TeachingCaseFrame[]
+}
+export interface TeachingCaseAdmin extends TeachingCasePublic, TeachingCaseInput {
+ consentedAt: string | null
+}
+export interface TeachingCasePage { items: T[]; total: number; page: number; limit: number }
+export interface TeachingCaseUpload { id: string; uploadUrl: string; formData: Record }