feat: 支持教学案例展示功能

This commit is contained in:
richarjiang
2026-09-18 18:26:47 +08:00
parent 95b9f6511c
commit 87afb786b9
28 changed files with 950 additions and 5 deletions

View File

@@ -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;

View File

@@ -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())
}

View File

@@ -9,6 +9,7 @@ const SENSITIVE_FIELDS: ReadonlySet<string> = new Set([
'sessionKey',
'encryptedData',
'iv',
'consentNote',
])
const BODY_METHODS: ReadonlySet<string> = new Set(['POST', 'PUT', 'PATCH'])

View File

@@ -0,0 +1,23 @@
import { ConfigService } from '@nestjs/config'
import { TeachingCaseStorageService } from '../teaching-case-storage.service'
describe('Teaching case private storage', () => {
const values: Record<string, string> = { 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()
})
})

View File

@@ -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<string, string> = { '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<Request & { user: { role: string } }>(); 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) })
})

View File

@@ -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<unknown>[]) => 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 } })
})
})

View File

@@ -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
}

View File

@@ -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 {}

View File

@@ -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 }) }
}
}

View File

@@ -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) }
}

View File

@@ -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 } : {}),
}))
}
}

View File

@@ -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`