Files
doodle-store/apps/server/src/content/content-policy.ts
T

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');
}
}