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 { return { disclosure: this.detail(await this.find(id)) }; } async getPublic(id: string): Promise { 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 { 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 { 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 { 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, ); } }