feat(finance): add financial disclosure publishing
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user