Files
lehuo/apps/api/src/financial-disclosures/financial-disclosures.service.ts
T

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