79 lines
3.0 KiB
TypeScript
79 lines
3.0 KiB
TypeScript
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');
|
|
}
|
|
}
|