feat: 完成分类、worksheet 接口开发,资源迁移到七牛
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import type { ContentStatus } from '@doodle/contracts';
|
||||
import { Prisma } from '../generated/prisma/client';
|
||||
import { requireCurrentAccount } from '../auth/account-policy';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
import { ApiException } from '../common/api-exception';
|
||||
|
||||
/** 只从 active 根节点展开;草稿祖先、失联或成环的分支均不对外可见。 */
|
||||
export const visibleCategoryIds = Prisma.sql`WITH RECURSIVE visible_categories AS (
|
||||
SELECT id FROM public.categories WHERE parent_id IS NULL AND status = 'active'
|
||||
UNION
|
||||
SELECT c.id FROM public.categories c JOIN visible_categories p ON c.parent_id = p.id
|
||||
WHERE c.status = 'active'
|
||||
) SELECT id FROM visible_categories`;
|
||||
|
||||
/** 题型列表、详情和管理预览共用规则,不能只过滤左侧分类菜单。SQL 中题型别名固定为 w。 */
|
||||
export const visibleWorksheet = Prisma.sql`w.status = 'active' AND w.category_id IN (${visibleCategoryIds})`;
|
||||
|
||||
/** 发布状态显式校验;不接受布尔值、NULL 或客户端自行添加的测试状态。 */
|
||||
export function contentStatus(value: unknown): ContentStatus {
|
||||
if (value !== 'draft' && value !== 'active' && value !== 'hidden')
|
||||
throw new BadRequestException('发布状态不合法');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 写事务重新核对当前账户并持锁,避免守卫之后被禁用或降权仍然提交。 */
|
||||
export async function lockContentAdmin(
|
||||
tx: Prisma.TransactionClient,
|
||||
actor: AuthenticatedUser,
|
||||
): Promise<void> {
|
||||
const rows = await tx.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
status: 'active' | 'disabled';
|
||||
role: 'user' | 'admin';
|
||||
authVersion: number;
|
||||
}>
|
||||
>`SELECT id, status, role, auth_version AS "authVersion" FROM public.users WHERE id = ${actor.userId} FOR UPDATE`;
|
||||
const account = requireCurrentAccount(
|
||||
rows[0] ?? null,
|
||||
actor.userId,
|
||||
actor.authVersion,
|
||||
);
|
||||
if (account.role !== 'admin') throw new ForbiddenException();
|
||||
}
|
||||
|
||||
/** 先撤销数据库展示快照与发布标记;外部已分发的静态文件须由后续发布链路撤销/重建。 */
|
||||
export async function invalidateContentSnapshots(
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
await tx.pageConfig.updateMany({
|
||||
data: {
|
||||
home: Prisma.DbNull,
|
||||
category: Prisma.DbNull,
|
||||
age: Prisma.DbNull,
|
||||
version: { increment: 1 },
|
||||
publishedVersion: null,
|
||||
publishedAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 保留业务 HTTP 错误;连接/约束诊断不原样输出给调用者。 */
|
||||
export async function runContentQuery<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new ApiException(503, 'DATABASE_UNAVAILABLE');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type {
|
||||
AdminResourcePage,
|
||||
AdminWorksheet,
|
||||
AdminWorksheetPage,
|
||||
AdminWorksheetResource,
|
||||
ApiSuccess,
|
||||
ContentPublication,
|
||||
} from '@doodle/contracts';
|
||||
import { CurrentUser, Roles } from '../auth/auth.decorators';
|
||||
import type { AuthenticatedUser, HttpRequest } from '../common/http-request';
|
||||
import { WorksheetService } from '../worksheets/worksheet.service';
|
||||
import {
|
||||
AdminResourceQueryDto,
|
||||
AdminWorksheetQueryDto,
|
||||
UpdateContentStatusDto,
|
||||
} from './content.dto';
|
||||
import { PublicationService } from './publication.service';
|
||||
import { ResourcePreviewService } from './resource-preview.service';
|
||||
|
||||
@Controller('admin/worksheets')
|
||||
@ApiTags('admin/worksheets')
|
||||
@Roles('admin')
|
||||
/** 管理预览使用本站 JWT 与当前管理员身份,不接受客户端自报测试身份。 */
|
||||
export class AdminWorksheetController {
|
||||
constructor(
|
||||
private readonly worksheets: WorksheetService,
|
||||
private readonly publication: PublicationService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '全部状态题型(管理员,支持状态及原有筛选)' })
|
||||
async list(
|
||||
@Query() query: AdminWorksheetQueryDto,
|
||||
): Promise<ApiSuccess<AdminWorksheetPage>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.worksheets.listForAdmin(query),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '题型管理预览(管理员,包含草稿及有效可见性)' })
|
||||
async get(@Param('id') id: string): Promise<ApiSuccess<AdminWorksheet>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.worksheets.getForAdmin(id),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ApiOperation({
|
||||
summary: '题型发布、下架或转草稿(管理员);发布要求分类已上线',
|
||||
})
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() input: UpdateContentStatusDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: HttpRequest,
|
||||
): Promise<ApiSuccess<ContentPublication>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.publication.update(
|
||||
'worksheet',
|
||||
id,
|
||||
input,
|
||||
actor,
|
||||
request.requestId!,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/worksheet-resources')
|
||||
@ApiTags('admin/worksheet-resources')
|
||||
@Roles('admin')
|
||||
/** 资源公开列表尚未开放;管理预览和上下架先落实同一发布规则。 */
|
||||
export class AdminResourceController {
|
||||
constructor(
|
||||
private readonly resources: ResourcePreviewService,
|
||||
private readonly publication: PublicationService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '分页查询资源(管理员,支持 worksheetId/status)',
|
||||
})
|
||||
async list(
|
||||
@Query() query: AdminResourceQueryDto,
|
||||
): Promise<ApiSuccess<AdminResourcePage>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.resources.list(query),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '资源管理预览(管理员,包含草稿及有效可见性)' })
|
||||
async get(
|
||||
@Param('id') id: string,
|
||||
): Promise<ApiSuccess<AdminWorksheetResource>> {
|
||||
return { code: 0, message: 'ok', data: await this.resources.get(id) };
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ApiOperation({
|
||||
summary: '资源发布、下架或转草稿(管理员);发布要求题型及分类已上线',
|
||||
})
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() input: UpdateContentStatusDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: HttpRequest,
|
||||
): Promise<ApiSuccess<ContentPublication>> {
|
||||
return {
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: await this.publication.update(
|
||||
'resource',
|
||||
id,
|
||||
input,
|
||||
actor,
|
||||
request.requestId!,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Allow } from 'class-validator';
|
||||
import type {
|
||||
ContentStatus,
|
||||
UpdateContentStatusRequest,
|
||||
} from '@doodle/contracts';
|
||||
import {
|
||||
WorksheetQueryDto,
|
||||
parseWorksheetQuery,
|
||||
} from '../worksheets/worksheet-query.dto';
|
||||
import { contentStatus } from './content-policy';
|
||||
|
||||
/** 管理列表才接受 status;公开接口不复用此 DTO,避免开放草稿查询开关。 */
|
||||
export class AdminWorksheetQueryDto extends WorksheetQueryDto {
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
enum: ['draft', 'active', 'hidden'],
|
||||
description: '省略查询全部状态',
|
||||
})
|
||||
declare status?: string;
|
||||
}
|
||||
|
||||
/** 首期只提供状态写入,不开放未经白名单校验的渲染配置编辑。 */
|
||||
export class UpdateContentStatusDto implements UpdateContentStatusRequest {
|
||||
@Allow()
|
||||
@ApiProperty({ enum: ['draft', 'active', 'hidden'] })
|
||||
declare status: ContentStatus;
|
||||
}
|
||||
|
||||
/** 资源管理列表仅支持分页、所属题型和状态,不隐式套用题型年龄/年级条件。 */
|
||||
export class AdminResourceQueryDto {
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
type: 'integer',
|
||||
default: 1,
|
||||
minimum: 1,
|
||||
maximum: 1000000,
|
||||
})
|
||||
declare page?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({
|
||||
type: 'integer',
|
||||
default: 20,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
})
|
||||
declare pageSize?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({ maxLength: 128 })
|
||||
declare worksheetId?: string;
|
||||
|
||||
@Allow()
|
||||
@ApiPropertyOptional({ enum: ['draft', 'active', 'hidden'] })
|
||||
declare status?: string;
|
||||
}
|
||||
|
||||
/** 服务层同样检查字段白名单,防止非 HTTP 调用绕过状态参数限制。 */
|
||||
export function parseAdminWorksheetQuery(input: AdminWorksheetQueryDto) {
|
||||
const { status, ...query } = input;
|
||||
return {
|
||||
query: parseWorksheetQuery(query),
|
||||
status: status === undefined ? undefined : contentStatus(status),
|
||||
};
|
||||
}
|
||||
|
||||
/** 严格解析资源分页和过滤;拒绝重复/未知字段,避免管理查询静默扩大范围。 */
|
||||
export function parseAdminResourceQuery(input: AdminResourceQueryDto) {
|
||||
if (
|
||||
Object.keys(input).some(
|
||||
(key) =>
|
||||
!['page', 'pageSize', 'worksheetId', 'status'].includes(key),
|
||||
)
|
||||
)
|
||||
throw new BadRequestException();
|
||||
const { page, pageSize } = parseWorksheetQuery({
|
||||
...(input.page === undefined ? {} : { page: input.page }),
|
||||
...(input.pageSize === undefined ? {} : { pageSize: input.pageSize }),
|
||||
});
|
||||
const worksheetId = input.worksheetId;
|
||||
if (
|
||||
worksheetId !== undefined &&
|
||||
(typeof worksheetId !== 'string' ||
|
||||
!worksheetId.trim() ||
|
||||
[...worksheetId].length > 128 ||
|
||||
worksheetId.includes('\0'))
|
||||
)
|
||||
throw new BadRequestException();
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
worksheetId,
|
||||
status:
|
||||
input.status === undefined
|
||||
? undefined
|
||||
: contentStatus(input.status),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** 仅解析已就绪的公开预览图;管理预览同样不绕过资产访问规则。 */
|
||||
export interface PreviewAssetRow {
|
||||
assetStatus: string;
|
||||
assetVisibility: string;
|
||||
assetPurpose: string;
|
||||
assetOwnerId: string | null;
|
||||
assetProvider: string;
|
||||
assetKey: string | null;
|
||||
assetExternalUrl: string | null;
|
||||
assetMimeType: string | null;
|
||||
}
|
||||
|
||||
/** 公开与管理共用安全的预览 URL 解析;尚未迁移、私有或签名地址返回空值。 */
|
||||
export function previewUrl(
|
||||
row: PreviewAssetRow,
|
||||
qiniuPublicDomain: string | null,
|
||||
): string | null {
|
||||
// 先查 ready 和公共用途;未迁移/失败/私有文件明确留空,不泄漏旧 cloud:// 或内部 key。
|
||||
if (
|
||||
row.assetStatus !== 'ready' ||
|
||||
row.assetVisibility !== 'public' ||
|
||||
row.assetPurpose !== 'preview' ||
|
||||
row.assetOwnerId !== null ||
|
||||
!row.assetMimeType?.startsWith('image/')
|
||||
)
|
||||
return null;
|
||||
if (row.assetProvider === 'external' && row.assetExternalUrl) {
|
||||
try {
|
||||
const url = new URL(row.assetExternalUrl);
|
||||
return url.protocol === 'https:' &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
!url.search &&
|
||||
!url.hash
|
||||
? url.href
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (row.assetProvider === 'qiniu' && row.assetKey && qiniuPublicDomain) {
|
||||
const parts = row.assetKey.split('/');
|
||||
if (
|
||||
row.assetKey.startsWith('/') ||
|
||||
row.assetKey.includes('\\') ||
|
||||
/[\r\n]/.test(row.assetKey) ||
|
||||
parts.some((part) => part === '.' || part === '..')
|
||||
)
|
||||
return null;
|
||||
return `${qiniuPublicDomain}/${parts.map((part) => encodeURIComponent(part)).join('/')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { ContentPublication } from '@doodle/contracts';
|
||||
import { Prisma } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import type { AuthenticatedUser } from '../common/http-request';
|
||||
import {
|
||||
contentStatus,
|
||||
invalidateContentSnapshots,
|
||||
lockContentAdmin,
|
||||
runContentQuery,
|
||||
visibleCategoryIds,
|
||||
visibleWorksheet,
|
||||
} from './content-policy';
|
||||
|
||||
interface PublicationRow {
|
||||
id: string;
|
||||
status: string;
|
||||
firstPublishedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** 状态、首次发布、页面快照失效与审计在同一事务提交;不进行外部上传。 */
|
||||
export class PublicationService {
|
||||
constructor(private readonly database: PrismaService) {}
|
||||
|
||||
/** 仅管理员路由调用;table 只取服务端常量,不从请求拼接 SQL 标识符。 */
|
||||
async update(
|
||||
target: 'worksheet' | 'resource',
|
||||
id: string,
|
||||
input: unknown,
|
||||
actor: AuthenticatedUser,
|
||||
requestId: string,
|
||||
): Promise<ContentPublication> {
|
||||
if (!id || [...id].length > 128 || id.includes('\0'))
|
||||
throw new NotFoundException();
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== 'object' ||
|
||||
Array.isArray(input) ||
|
||||
Object.keys(input).some((key) => key !== 'status')
|
||||
)
|
||||
throw new BadRequestException();
|
||||
const status = contentStatus((input as Record<string, unknown>).status);
|
||||
const table =
|
||||
target === 'worksheet'
|
||||
? Prisma.sql`public.worksheets`
|
||||
: Prisma.sql`public.worksheet_resources`;
|
||||
return runContentQuery(() =>
|
||||
this.database.client.$transaction(
|
||||
async (tx) => {
|
||||
await lockContentAdmin(tx, actor);
|
||||
// 与分类变更及导入采用相同锁顺序;资源发布总是先锁父功能,再锁资源。
|
||||
await tx.$executeRaw`LOCK TABLE public.categories IN SHARE ROW EXCLUSIVE MODE`;
|
||||
if (target === 'resource') {
|
||||
const parents = await tx.$queryRaw<
|
||||
Array<{ worksheetId: string }>
|
||||
>`
|
||||
SELECT worksheet_id AS "worksheetId" FROM public.worksheet_resources WHERE id = ${id}
|
||||
`;
|
||||
if (!parents[0]) throw new NotFoundException();
|
||||
await tx.$queryRaw`SELECT id FROM public.worksheets WHERE id = ${parents[0].worksheetId} FOR UPDATE`;
|
||||
}
|
||||
const rows = await tx.$queryRaw<
|
||||
PublicationRow[]
|
||||
>(Prisma.sql`
|
||||
SELECT id, status, first_published_at AS "firstPublishedAt", updated_at AS "updatedAt"
|
||||
FROM ${table} WHERE id = ${id} FOR UPDATE
|
||||
`);
|
||||
const before = rows[0];
|
||||
if (!before) throw new NotFoundException();
|
||||
if (before.status === status)
|
||||
return this.toPublication(before);
|
||||
if (status === 'active') {
|
||||
const available =
|
||||
target === 'worksheet'
|
||||
? await tx.$queryRaw<
|
||||
Array<{ id: string }>
|
||||
>`SELECT id FROM public.worksheets WHERE id = ${id} AND category_id IN (${visibleCategoryIds})`
|
||||
: await tx.$queryRaw<
|
||||
Array<{ id: string }>
|
||||
>`SELECT r.id FROM public.worksheet_resources r JOIN public.worksheets w ON w.id = r.worksheet_id WHERE r.id = ${id} AND (${visibleWorksheet})`;
|
||||
if (!available.length)
|
||||
throw new ConflictException(
|
||||
'上级尚未上线,不能发布',
|
||||
);
|
||||
}
|
||||
const now = new Date();
|
||||
// 旧 active/hidden 的未知时间保持 NULL;首次从草稿发布才登记,不因重新上架重置。
|
||||
// 有下架审计的旧记录即使被改回草稿,也不能把未知历史伪装成本次首发。
|
||||
const history =
|
||||
before.status === 'draft' &&
|
||||
status === 'active' &&
|
||||
!before.firstPublishedAt
|
||||
? await tx.adminAuditLog.findMany({
|
||||
where: {
|
||||
resourceType:
|
||||
target === 'worksheet'
|
||||
? 'worksheets'
|
||||
: 'worksheet_resources',
|
||||
resourceId: id,
|
||||
},
|
||||
orderBy: [
|
||||
{ createdAt: 'asc' },
|
||||
{ id: 'asc' },
|
||||
],
|
||||
select: { beforeData: true, afterData: true },
|
||||
})
|
||||
: [];
|
||||
const previousPublication =
|
||||
history.some(
|
||||
(entry) =>
|
||||
this.snapshotStatus(entry.beforeData) ===
|
||||
'active' ||
|
||||
this.snapshotStatus(entry.afterData) ===
|
||||
'active',
|
||||
) ||
|
||||
['active', 'hidden'].includes(
|
||||
this.snapshotStatus(history[0]?.beforeData) ??
|
||||
this.snapshotStatus(history[0]?.afterData) ??
|
||||
'',
|
||||
);
|
||||
const firstPublishedAt =
|
||||
before.firstPublishedAt ??
|
||||
(before.status === 'draft' &&
|
||||
status === 'active' &&
|
||||
!previousPublication
|
||||
? now
|
||||
: null);
|
||||
const after = (
|
||||
await tx.$queryRaw<PublicationRow[]>(Prisma.sql`
|
||||
UPDATE ${table} SET status = ${status}, first_published_at = ${firstPublishedAt}, updated_at = ${now}
|
||||
WHERE id = ${id} RETURNING id, status, first_published_at AS "firstPublishedAt", updated_at AS "updatedAt"
|
||||
`)
|
||||
)[0]!;
|
||||
await invalidateContentSnapshots(tx);
|
||||
await tx.adminAuditLog.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
actorType: 'admin',
|
||||
actorUserId: actor.userId,
|
||||
action: `${target}.status`,
|
||||
resourceType:
|
||||
target === 'worksheet'
|
||||
? 'worksheets'
|
||||
: 'worksheet_resources',
|
||||
resourceId: id,
|
||||
requestId,
|
||||
beforeData: { ...this.toPublication(before) },
|
||||
afterData: { ...this.toPublication(after) },
|
||||
},
|
||||
});
|
||||
return this.toPublication(after);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 首条历史即为下架/上线时,不将旧未知发布时间伪装为本次首发。 */
|
||||
private snapshotStatus(
|
||||
value: Prisma.JsonValue | undefined,
|
||||
): string | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return undefined;
|
||||
return typeof value.status === 'string' ? value.status : undefined;
|
||||
}
|
||||
|
||||
private toPublication(row: PublicationRow): ContentPublication {
|
||||
return {
|
||||
id: row.id,
|
||||
status: contentStatus(row.status),
|
||||
firstPublishedAt: row.firstPublishedAt?.toISOString() ?? null,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
AdminResourcePage,
|
||||
AdminWorksheetResource,
|
||||
} from '@doodle/contracts';
|
||||
import { Prisma } from '../generated/prisma/client';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
import {
|
||||
contentStatus,
|
||||
runContentQuery,
|
||||
visibleWorksheet,
|
||||
} from './content-policy';
|
||||
import {
|
||||
parseAdminResourceQuery,
|
||||
type AdminResourceQueryDto,
|
||||
} from './content.dto';
|
||||
import { previewUrl, type PreviewAssetRow } from './preview-url';
|
||||
|
||||
interface ResourceRow extends PreviewAssetRow {
|
||||
id: string;
|
||||
worksheetId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
downloads: bigint;
|
||||
firstPublishedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** 管理员可检查草稿资源;预览仍只解析公开资产,不直接返回参考图和参数覆盖。 */
|
||||
export class ResourcePreviewService {
|
||||
constructor(
|
||||
private readonly database: PrismaService,
|
||||
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
|
||||
) {}
|
||||
|
||||
async list(input: AdminResourceQueryDto): Promise<AdminResourcePage> {
|
||||
const { page, pageSize, worksheetId, status } =
|
||||
parseAdminResourceQuery(input);
|
||||
const parts = [Prisma.sql`TRUE`];
|
||||
if (worksheetId !== undefined)
|
||||
parts.push(Prisma.sql`r.worksheet_id = ${worksheetId}`);
|
||||
if (status !== undefined) parts.push(Prisma.sql`r.status = ${status}`);
|
||||
const where = Prisma.join(parts, ' AND ');
|
||||
return runContentQuery(() =>
|
||||
this.database.client.$transaction(
|
||||
async (tx) => {
|
||||
const counts = await tx.$queryRaw<
|
||||
Array<{ total: bigint }>
|
||||
>`SELECT count(*) AS total FROM public.worksheet_resources r WHERE ${where}`;
|
||||
const total = Number(counts[0]!.total);
|
||||
if (!Number.isSafeInteger(total))
|
||||
throw new Error('资源总数超出安全范围');
|
||||
const rows = await tx.$queryRaw<
|
||||
ResourceRow[]
|
||||
>`SELECT ${this.columns()}
|
||||
FROM public.worksheet_resources r JOIN public.worksheets w ON w.id = r.worksheet_id
|
||||
JOIN public.assets a ON a.id = r.preview_asset_id WHERE ${where}
|
||||
ORDER BY r.sort_order, r.id LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`;
|
||||
return {
|
||||
items: rows.map((row) => this.convert(row)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
hasNext: page * pageSize < total,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: 'RepeatableRead', timeout: 10_000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<AdminWorksheetResource> {
|
||||
if (!id || [...id].length > 128 || id.includes('\0'))
|
||||
throw new NotFoundException();
|
||||
return runContentQuery(async () => {
|
||||
const rows = await this.database.client.$queryRaw<
|
||||
ResourceRow[]
|
||||
>`SELECT ${this.columns()}
|
||||
FROM public.worksheet_resources r JOIN public.worksheets w ON w.id = r.worksheet_id
|
||||
JOIN public.assets a ON a.id = r.preview_asset_id WHERE r.id = ${id}`;
|
||||
if (!rows[0]) throw new NotFoundException();
|
||||
return this.convert(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
private columns(): Prisma.Sql {
|
||||
return Prisma.sql`r.id, r.worksheet_id AS "worksheetId", r.title, r.status,
|
||||
r.sort_order AS "sortOrder", r.downloads, r.first_published_at AS "firstPublishedAt",
|
||||
r.created_at AS "createdAt", r.updated_at AS "updatedAt",
|
||||
(r.status = 'active' AND (${visibleWorksheet})) AS visible,
|
||||
a.status AS "assetStatus", a.visibility AS "assetVisibility", a.purpose AS "assetPurpose",
|
||||
a.owner_user_id AS "assetOwnerId", a.provider AS "assetProvider", a.object_key AS "assetKey",
|
||||
a.external_url AS "assetExternalUrl", a.mime_type AS "assetMimeType"`;
|
||||
}
|
||||
|
||||
private convert(row: ResourceRow): AdminWorksheetResource {
|
||||
return {
|
||||
id: row.id,
|
||||
worksheetId: row.worksheetId,
|
||||
title: row.title,
|
||||
status: contentStatus(row.status),
|
||||
previewImg: previewUrl(row, this.config.qiniuPublicDomain),
|
||||
sortOrder: row.sortOrder,
|
||||
downloads: row.downloads.toString(),
|
||||
visible: row.visible,
|
||||
firstPublishedAt: row.firstPublishedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user