feat: 支持教学案例展示功能
This commit is contained in:
@@ -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'])
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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) })
|
||||
})
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
31
packages/server/src/studio/dto/teaching-case.dto.ts
Normal file
31
packages/server/src/studio/dto/teaching-case.dto.ts
Normal 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
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
17
packages/server/src/studio/teaching-case-storage.service.ts
Normal file
17
packages/server/src/studio/teaching-case-storage.service.ts
Normal 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 }) }
|
||||
}
|
||||
}
|
||||
29
packages/server/src/studio/teaching-case.controller.ts
Normal file
29
packages/server/src/studio/teaching-case.controller.ts
Normal 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) }
|
||||
}
|
||||
82
packages/server/src/studio/teaching-case.service.ts
Normal file
82
packages/server/src/studio/teaching-case.service.ts
Normal 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 } : {}),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user