342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
|
import { randomUUID } from 'node:crypto';
|
|
import sanitizeHtml from 'sanitize-html';
|
|
import {
|
|
API_ERROR_CODES,
|
|
type FinancialDisclosureDetail,
|
|
type FinancialDisclosureDetailResponse,
|
|
type PaginatedFinancialDisclosuresResponse,
|
|
} from '@lehuo/contracts';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { ApplicationError } from '../common/application-error';
|
|
import type { AuthContext, RequestContext } from '../common/request-context';
|
|
import { PrismaService } from '../database/prisma.service';
|
|
import type { FinancialDisclosure, Prisma } from '../generated/prisma/client';
|
|
import { SensitiveDataService } from '../security/sensitive-data.service';
|
|
import type {
|
|
CreateFinancialDisclosureDto,
|
|
FinancialDisclosureQueryDto,
|
|
TransitionFinancialDisclosureDto,
|
|
UpdateFinancialDisclosureDto,
|
|
} from './dto/financial-disclosure.dto';
|
|
|
|
const clean = (html: string) =>
|
|
sanitizeHtml(html, {
|
|
allowedTags: [
|
|
'p',
|
|
'br',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'strong',
|
|
'b',
|
|
'em',
|
|
'i',
|
|
'u',
|
|
's',
|
|
'blockquote',
|
|
'ul',
|
|
'ol',
|
|
'li',
|
|
'table',
|
|
'thead',
|
|
'tbody',
|
|
'tr',
|
|
'th',
|
|
'td',
|
|
'a',
|
|
'img',
|
|
'hr',
|
|
'span',
|
|
],
|
|
allowedAttributes: {
|
|
a: ['href', 'title'],
|
|
img: ['src', 'alt', 'title'],
|
|
th: ['colspan', 'rowspan'],
|
|
td: ['colspan', 'rowspan'],
|
|
span: ['style'],
|
|
p: ['style'],
|
|
h1: ['style'],
|
|
h2: ['style'],
|
|
h3: ['style'],
|
|
},
|
|
allowedStyles: {
|
|
'*': {
|
|
'text-align': [/^(left|right|center|justify)$/],
|
|
color: [/^#[0-9a-f]{3,6}$/i],
|
|
'background-color': [/^#[0-9a-f]{3,6}$/i],
|
|
},
|
|
},
|
|
allowedSchemes: ['https'],
|
|
allowedSchemesByTag: { img: ['https'] },
|
|
enforceHtmlBoundary: true,
|
|
});
|
|
|
|
@Injectable()
|
|
export class FinancialDisclosuresService {
|
|
constructor(
|
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
|
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
|
|
@Inject(AuditService) private readonly audit: AuditService,
|
|
) {}
|
|
listAdmin(q: FinancialDisclosureQueryDto) {
|
|
return this.list(q.status ? { status: q.status as never } : {}, q.page, q.pageSize);
|
|
}
|
|
listPublic(q: FinancialDisclosureQueryDto) {
|
|
return this.list({ status: 'PUBLISHED' }, q.page, q.pageSize);
|
|
}
|
|
async getAdmin(id: string): Promise<FinancialDisclosureDetailResponse> {
|
|
return { disclosure: this.detail(await this.find(id)) };
|
|
}
|
|
async getPublic(id: string): Promise<FinancialDisclosureDetailResponse> {
|
|
const item = await this.find(id);
|
|
if (item.status !== 'PUBLISHED') throw this.notFound();
|
|
return { disclosure: this.detail(item) };
|
|
}
|
|
|
|
async create(
|
|
input: CreateFinancialDisclosureDto,
|
|
actor: AuthContext,
|
|
req: RequestContext,
|
|
): Promise<FinancialDisclosureDetailResponse & { idempotentReplay: boolean }> {
|
|
const richText = this.sanitize(input.richText),
|
|
fp = this.fingerprint('financial-disclosure-create', { ...input, richText });
|
|
const replay = await this.prisma.financialDisclosure.findUnique({
|
|
where: {
|
|
createdById_clientRequestId: {
|
|
createdById: actor.userId,
|
|
clientRequestId: input.clientRequestId,
|
|
},
|
|
},
|
|
});
|
|
if (replay) {
|
|
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
|
return { disclosure: this.detail(replay), idempotentReplay: true };
|
|
}
|
|
const item = await this.prisma.$transaction(async (tx) => {
|
|
const created = await tx.financialDisclosure.create({
|
|
data: {
|
|
publicId: randomUUID(),
|
|
clientRequestId: input.clientRequestId,
|
|
requestFingerprint: fp,
|
|
title: input.title.trim(),
|
|
summary: input.summary?.trim() || null,
|
|
richText,
|
|
isPinned: input.isPinned ?? false,
|
|
sortOrder: input.sortOrder ?? 0,
|
|
createdById: actor.userId,
|
|
updatedById: actor.userId,
|
|
},
|
|
});
|
|
await tx.financialDisclosureStatusHistory.create({
|
|
data: {
|
|
financialDisclosureId: created.id,
|
|
requestId: input.clientRequestId,
|
|
requestFingerprint: fp,
|
|
toStatus: 'DRAFT',
|
|
operatorUserId: actor.userId,
|
|
},
|
|
});
|
|
await this.audit.record(
|
|
req,
|
|
{
|
|
action: 'FINANCIAL_DISCLOSURE_CREATED',
|
|
result: 'SUCCESS',
|
|
actor,
|
|
resourceType: 'FINANCIAL_DISCLOSURE',
|
|
resourcePublicId: created.publicId,
|
|
},
|
|
tx,
|
|
);
|
|
return created;
|
|
});
|
|
return { disclosure: this.detail(item), idempotentReplay: false };
|
|
}
|
|
|
|
async update(
|
|
id: string,
|
|
input: UpdateFinancialDisclosureDto,
|
|
actor: AuthContext,
|
|
req: RequestContext,
|
|
): Promise<FinancialDisclosureDetailResponse> {
|
|
const current = await this.find(id);
|
|
if (current.status !== 'DRAFT') throw this.invalid();
|
|
if (current.revision !== input.expectedRevision) throw this.conflict();
|
|
const richText = this.sanitize(input.richText);
|
|
const updated = await this.prisma.$transaction(async (tx) => {
|
|
const result = await tx.financialDisclosure.updateMany({
|
|
where: { id: current.id, revision: input.expectedRevision, status: 'DRAFT' },
|
|
data: {
|
|
title: input.title.trim(),
|
|
summary: input.summary?.trim() || null,
|
|
richText,
|
|
isPinned: input.isPinned ?? false,
|
|
sortOrder: input.sortOrder ?? 0,
|
|
updatedById: actor.userId,
|
|
revision: { increment: 1 },
|
|
},
|
|
});
|
|
if (result.count !== 1) throw this.conflict();
|
|
await this.audit.record(
|
|
req,
|
|
{
|
|
action: 'FINANCIAL_DISCLOSURE_UPDATED',
|
|
result: 'SUCCESS',
|
|
actor,
|
|
resourceType: 'FINANCIAL_DISCLOSURE',
|
|
resourcePublicId: id,
|
|
},
|
|
tx,
|
|
);
|
|
return tx.financialDisclosure.findUniqueOrThrow({ where: { id: current.id } });
|
|
});
|
|
return { disclosure: this.detail(updated) };
|
|
}
|
|
|
|
async transition(
|
|
id: string,
|
|
input: TransitionFinancialDisclosureDto,
|
|
actor: AuthContext,
|
|
req: RequestContext,
|
|
) {
|
|
const current = await this.find(id),
|
|
fp = this.fingerprint('financial-disclosure-transition', {
|
|
id,
|
|
targetStatus: input.targetStatus,
|
|
});
|
|
const replay = await this.prisma.financialDisclosureStatusHistory.findUnique({
|
|
where: { requestId: input.clientRequestId },
|
|
include: { financialDisclosure: true },
|
|
});
|
|
if (replay) {
|
|
if (replay.requestFingerprint !== fp) throw this.duplicate();
|
|
return { disclosure: this.detail(replay.financialDisclosure), idempotentReplay: true };
|
|
}
|
|
const allowed =
|
|
(current.status === 'DRAFT' && input.targetStatus === 'PUBLISHED') ||
|
|
(current.status === 'PUBLISHED' && input.targetStatus === 'ARCHIVED');
|
|
if (!allowed) throw this.invalid();
|
|
const updated = await this.prisma.$transaction(async (tx) => {
|
|
const result = await tx.financialDisclosure.updateMany({
|
|
where: { id: current.id, status: current.status },
|
|
data: {
|
|
status: input.targetStatus,
|
|
updatedById: actor.userId,
|
|
revision: { increment: 1 },
|
|
...(input.targetStatus === 'PUBLISHED' ? { publishedAt: new Date() } : {}),
|
|
},
|
|
});
|
|
if (result.count !== 1) throw this.conflict();
|
|
await tx.financialDisclosureStatusHistory.create({
|
|
data: {
|
|
financialDisclosureId: current.id,
|
|
requestId: input.clientRequestId,
|
|
requestFingerprint: fp,
|
|
fromStatus: current.status,
|
|
toStatus: input.targetStatus,
|
|
operatorUserId: actor.userId,
|
|
},
|
|
});
|
|
await this.audit.record(
|
|
req,
|
|
{
|
|
action: 'FINANCIAL_DISCLOSURE_STATUS_CHANGED',
|
|
result: 'SUCCESS',
|
|
actor,
|
|
resourceType: 'FINANCIAL_DISCLOSURE',
|
|
resourcePublicId: id,
|
|
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
|
|
},
|
|
tx,
|
|
);
|
|
return tx.financialDisclosure.findUniqueOrThrow({ where: { id: current.id } });
|
|
});
|
|
return { disclosure: this.detail(updated), idempotentReplay: false };
|
|
}
|
|
|
|
private async list(
|
|
where: Prisma.FinancialDisclosureWhereInput,
|
|
page: number,
|
|
pageSize: number,
|
|
): Promise<PaginatedFinancialDisclosuresResponse> {
|
|
const [total, items] = await this.prisma.$transaction([
|
|
this.prisma.financialDisclosure.count({ where }),
|
|
this.prisma.financialDisclosure.findMany({
|
|
where,
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ sortOrder: 'desc' },
|
|
{ publishedAt: 'desc' },
|
|
{ updatedAt: 'desc' },
|
|
],
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
]);
|
|
return { items: items.map((i) => this.item(i)), page, pageSize, total };
|
|
}
|
|
private async find(publicId: string) {
|
|
const item = await this.prisma.financialDisclosure.findUnique({ where: { publicId } });
|
|
if (!item) throw this.notFound();
|
|
return item;
|
|
}
|
|
private item(i: FinancialDisclosure) {
|
|
return {
|
|
publicId: i.publicId,
|
|
title: i.title,
|
|
summary: i.summary,
|
|
status: i.status,
|
|
isPinned: i.isPinned,
|
|
sortOrder: i.sortOrder,
|
|
revision: i.revision,
|
|
publishedAt: i.publishedAt?.toISOString() ?? null,
|
|
updatedAt: i.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
private detail(i: FinancialDisclosure): FinancialDisclosureDetail {
|
|
return { ...this.item(i), richText: i.richText };
|
|
}
|
|
private sanitize(value: string) {
|
|
const result = clean(value).trim();
|
|
if (!result || !sanitizeHtml(result, { allowedTags: [], allowedAttributes: {} }).trim())
|
|
throw new ApplicationError(
|
|
API_ERROR_CODES.VALIDATION_ERROR,
|
|
'财务公开正文不能为空',
|
|
HttpStatus.BAD_REQUEST,
|
|
);
|
|
return result;
|
|
}
|
|
private fingerprint(domain: string, value: object) {
|
|
return this.sensitive.lookupHash(domain, JSON.stringify(value));
|
|
}
|
|
private notFound() {
|
|
return new ApplicationError(
|
|
API_ERROR_CODES.RESOURCE_NOT_FOUND,
|
|
'未找到该财务公开文档',
|
|
HttpStatus.NOT_FOUND,
|
|
);
|
|
}
|
|
private invalid() {
|
|
return new ApplicationError(
|
|
API_ERROR_CODES.INVALID_STATE_TRANSITION,
|
|
'当前文档状态不能执行该操作',
|
|
HttpStatus.CONFLICT,
|
|
);
|
|
}
|
|
private conflict() {
|
|
return new ApplicationError(
|
|
API_ERROR_CODES.RESOURCE_CONFLICT,
|
|
'文档已更新,请刷新后重试',
|
|
HttpStatus.CONFLICT,
|
|
);
|
|
}
|
|
private duplicate() {
|
|
return new ApplicationError(
|
|
API_ERROR_CODES.DUPLICATE_REQUEST,
|
|
'请求标识已用于其他操作',
|
|
HttpStatus.CONFLICT,
|
|
);
|
|
}
|
|
}
|