feat(finance): add financial disclosure publishing
This commit is contained in:
@@ -31,13 +31,15 @@
|
||||
"cookie-parser": "^1.4.7",
|
||||
"mariadb": "^3.5.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-html": "^2.17.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.6",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"prisma": "^7.9.1",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
CREATE TABLE `financial_disclosures` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`public_id` CHAR(36) NOT NULL,
|
||||
`client_request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`title` VARCHAR(200) NOT NULL,
|
||||
`summary` VARCHAR(500) NULL,
|
||||
`rich_text` MEDIUMTEXT NOT NULL,
|
||||
`status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NOT NULL DEFAULT 'DRAFT',
|
||||
`is_pinned` BOOLEAN NOT NULL DEFAULT false,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`published_at` DATETIME(3) NULL,
|
||||
`created_by` BIGINT UNSIGNED NOT NULL,
|
||||
`updated_by` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
UNIQUE INDEX `financial_disclosures_public_id_key` (`public_id`),
|
||||
UNIQUE INDEX `financial_disclosures_creator_request_key` (`created_by`, `client_request_id`),
|
||||
INDEX `financial_disclosures_public_list_idx` (`status`, `is_pinned`, `sort_order`, `published_at`),
|
||||
INDEX `financial_disclosures_updated_at_idx` (`updated_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `financial_disclosure_status_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`financial_disclosure_id` BIGINT UNSIGNED NOT NULL,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`request_fingerprint` CHAR(64) NOT NULL,
|
||||
`from_status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NULL,
|
||||
`to_status` ENUM('DRAFT','PUBLISHED','ARCHIVED') NOT NULL,
|
||||
`operator_user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
UNIQUE INDEX `financial_disclosure_status_history_request_id_key` (`request_id`),
|
||||
INDEX `financial_disclosure_history_document_idx` (`financial_disclosure_id`, `created_at`),
|
||||
INDEX `financial_disclosure_history_operator_idx` (`operator_user_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `financial_disclosures` ADD CONSTRAINT `financial_disclosures_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `financial_disclosures` ADD CONSTRAINT `financial_disclosures_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `financial_disclosure_status_history` ADD CONSTRAINT `financial_disclosure_history_document_fkey` FOREIGN KEY (`financial_disclosure_id`) REFERENCES `financial_disclosures` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE `financial_disclosure_status_history` ADD CONSTRAINT `financial_disclosure_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
|
||||
('financial_disclosures:read', '查看财务公开', '查看财务公开草稿与发布状态'),
|
||||
('financial_disclosures:manage', '管理财务公开', '创建和编辑财务公开文档'),
|
||||
('financial_disclosures:publish', '发布财务公开', '发布和归档财务公开文档');
|
||||
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
|
||||
WHERE r.code IN ('ADMIN','SUPER_ADMIN')
|
||||
AND p.code IN ('financial_disclosures:read','financial_disclosures:manage','financial_disclosures:publish');
|
||||
@@ -170,6 +170,12 @@ enum ContentPostStatus {
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum FinancialDisclosureStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
APPLICATION_SUBMITTED
|
||||
APPLICATION_ACCEPTED
|
||||
@@ -316,6 +322,9 @@ model User {
|
||||
contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
|
||||
notifications Notification[]
|
||||
notificationPreferences NotificationPreference[]
|
||||
createdFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureCreator")
|
||||
updatedFinancialDisclosures FinancialDisclosure[] @relation("FinancialDisclosureUpdater")
|
||||
financialDisclosureStatusChanges FinancialDisclosureStatusHistory[] @relation("FinancialDisclosureStatusOperator")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -1148,6 +1157,50 @@ model ContentPostStatusHistory {
|
||||
@@map("content_post_status_history")
|
||||
}
|
||||
|
||||
model FinancialDisclosure {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
clientRequestId String @map("client_request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
title String @db.VarChar(200)
|
||||
summary String? @db.VarChar(500)
|
||||
richText String @map("rich_text") @db.MediumText
|
||||
status FinancialDisclosureStatus @default(DRAFT)
|
||||
isPinned Boolean @default(false) @map("is_pinned")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
revision Int @default(1) @db.UnsignedInt
|
||||
publishedAt DateTime? @map("published_at") @db.DateTime(3)
|
||||
createdById BigInt @map("created_by") @db.UnsignedBigInt
|
||||
updatedById BigInt @map("updated_by") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
creator User @relation("FinancialDisclosureCreator", fields: [createdById], references: [id], onDelete: Restrict)
|
||||
updater User @relation("FinancialDisclosureUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
|
||||
statusHistory FinancialDisclosureStatusHistory[]
|
||||
|
||||
@@unique([createdById, clientRequestId], map: "financial_disclosures_creator_request_key")
|
||||
@@index([status, isPinned, sortOrder, publishedAt], map: "financial_disclosures_public_list_idx")
|
||||
@@index([updatedAt], map: "financial_disclosures_updated_at_idx")
|
||||
@@map("financial_disclosures")
|
||||
}
|
||||
|
||||
model FinancialDisclosureStatusHistory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
financialDisclosureId BigInt @map("financial_disclosure_id") @db.UnsignedBigInt
|
||||
requestId String @unique @map("request_id") @db.Char(36)
|
||||
requestFingerprint String @map("request_fingerprint") @db.Char(64)
|
||||
fromStatus FinancialDisclosureStatus? @map("from_status")
|
||||
toStatus FinancialDisclosureStatus @map("to_status")
|
||||
operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
financialDisclosure FinancialDisclosure @relation(fields: [financialDisclosureId], references: [id], onDelete: Restrict)
|
||||
operator User @relation("FinancialDisclosureStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([financialDisclosureId, createdAt], map: "financial_disclosure_history_document_idx")
|
||||
@@index([operatorUserId, createdAt], map: "financial_disclosure_history_operator_idx")
|
||||
@@map("financial_disclosure_status_history")
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
publicId String @unique @map("public_id") @db.Char(36)
|
||||
|
||||
@@ -25,6 +25,7 @@ import { StatisticsModule } from './statistics/statistics.module';
|
||||
import { RequestMetricsService } from './common/request-metrics.service';
|
||||
import { RequestMetricsInterceptor } from './common/request-metrics.interceptor';
|
||||
import { SystemMetricsController } from './common/system-metrics.controller';
|
||||
import { FinancialDisclosuresModule } from './financial-disclosures/financial-disclosures.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -42,6 +43,7 @@ import { SystemMetricsController } from './common/system-metrics.controller';
|
||||
ParticipationModule,
|
||||
ContentNotificationsModule,
|
||||
StatisticsModule,
|
||||
FinancialDisclosuresModule,
|
||||
],
|
||||
controllers: [HealthController, SystemMetricsController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { FINANCIAL_DISCLOSURE_STATUSES } from '@lehuo/contracts';
|
||||
import { PaginationDto } from '../../case-management/dto/case-management.dto';
|
||||
|
||||
export class FinancialDisclosureQueryDto extends PaginationDto {
|
||||
@IsOptional() @IsIn(FINANCIAL_DISCLOSURE_STATUSES) status?: string;
|
||||
}
|
||||
export class CreateFinancialDisclosureDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsString() @Length(2, 200) title!: string;
|
||||
@IsOptional() @IsString() @Length(0, 500) summary?: string;
|
||||
@IsString() @Length(1, 1_000_000) richText!: string;
|
||||
@IsOptional() @IsBoolean() isPinned?: boolean;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||
}
|
||||
export class UpdateFinancialDisclosureDto {
|
||||
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
|
||||
@IsString() @Length(2, 200) title!: string;
|
||||
@IsOptional() @IsString() @Length(0, 500) summary?: string;
|
||||
@IsString() @Length(1, 1_000_000) richText!: string;
|
||||
@IsOptional() @IsBoolean() isPinned?: boolean;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
|
||||
}
|
||||
export class TransitionFinancialDisclosureDto {
|
||||
@IsUUID('4') clientRequestId!: string;
|
||||
@IsIn(['PUBLISHED', 'ARCHIVED']) targetStatus!: 'PUBLISHED' | 'ARCHIVED';
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { PERMISSION_CODES } from '@lehuo/contracts';
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import {
|
||||
CreateFinancialDisclosureDto,
|
||||
FinancialDisclosureQueryDto,
|
||||
TransitionFinancialDisclosureDto,
|
||||
UpdateFinancialDisclosureDto,
|
||||
} from './dto/financial-disclosure.dto';
|
||||
import { FinancialDisclosuresService } from './financial-disclosures.service';
|
||||
|
||||
const strict = (expectedType: new () => object) =>
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType,
|
||||
});
|
||||
|
||||
@Controller('api/v1/financial-disclosures')
|
||||
@Public()
|
||||
export class PublicFinancialDisclosuresController {
|
||||
constructor(
|
||||
@Inject(FinancialDisclosuresService) private readonly service: FinancialDisclosuresService,
|
||||
) {}
|
||||
@Get() list(@Query(strict(FinancialDisclosureQueryDto)) q: FinancialDisclosureQueryDto) {
|
||||
return this.service.listPublic(q);
|
||||
}
|
||||
@Get(':publicId') get(@Param('publicId') id: string) {
|
||||
return this.service.getPublic(id);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('api/v1/admin/financial-disclosures')
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
export class AdminFinancialDisclosuresController {
|
||||
constructor(
|
||||
@Inject(FinancialDisclosuresService) private readonly service: FinancialDisclosuresService,
|
||||
) {}
|
||||
@Get() @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_READ) list(
|
||||
@Query(strict(FinancialDisclosureQueryDto)) q: FinancialDisclosureQueryDto,
|
||||
) {
|
||||
return this.service.listAdmin(q);
|
||||
}
|
||||
@Get(':publicId') @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_READ) get(
|
||||
@Param('publicId') id: string,
|
||||
) {
|
||||
return this.service.getAdmin(id);
|
||||
}
|
||||
@Post() @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_MANAGE) create(
|
||||
@Body(strict(CreateFinancialDisclosureDto)) input: CreateFinancialDisclosureDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.create(input, actor, req);
|
||||
}
|
||||
@Patch(':publicId') @RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_MANAGE) update(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(UpdateFinancialDisclosureDto)) input: UpdateFinancialDisclosureDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.update(id, input, actor, req);
|
||||
}
|
||||
@Post(':publicId/transition')
|
||||
@RequirePermissions(PERMISSION_CODES.FINANCIAL_DISCLOSURES_PUBLISH)
|
||||
transition(
|
||||
@Param('publicId') id: string,
|
||||
@Body(strict(TransitionFinancialDisclosureDto)) input: TransitionFinancialDisclosureDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() req: RequestContext,
|
||||
) {
|
||||
return this.service.transition(id, input, actor, req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import {
|
||||
AdminFinancialDisclosuresController,
|
||||
PublicFinancialDisclosuresController,
|
||||
} from './financial-disclosures.controller';
|
||||
import { FinancialDisclosuresService } from './financial-disclosures.service';
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [PublicFinancialDisclosuresController, AdminFinancialDisclosuresController],
|
||||
providers: [FinancialDisclosuresService],
|
||||
})
|
||||
export class FinancialDisclosuresModule {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,8 @@ describe('Phase 1 authentication and authorization', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
|
||||
@@ -106,6 +106,8 @@ describe('Phase 3 beneficiary and case management', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
await prisma.notificationPreference.deleteMany();
|
||||
|
||||
@@ -51,6 +51,8 @@ describe('Phase 6 content and notifications', () => {
|
||||
passwords = app.get(PasswordService);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'node:http';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
ApiErrorResponse,
|
||||
ApiSuccessResponse,
|
||||
FinancialDisclosureDetailResponse,
|
||||
PaginatedFinancialDisclosuresResponse,
|
||||
} from '@lehuo/contracts';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PasswordService } from '../src/auth/password.service';
|
||||
import { ADMIN_SESSION_COOKIE } from '../src/auth/session.constants';
|
||||
import { PrismaService } from '../src/database/prisma.service';
|
||||
|
||||
const ORIGIN = 'http://localhost:5173';
|
||||
const body = <T>(response: { body: unknown }) => response.body as T;
|
||||
function sessionCookie(response: { headers: Record<string, unknown> }) {
|
||||
const values = response.headers['set-cookie'];
|
||||
const cookies: string[] = Array.isArray(values)
|
||||
? values.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
const found = cookies.find((value) => value.startsWith(`${ADMIN_SESSION_COOKIE}=`));
|
||||
if (!found) throw new Error('no cookie');
|
||||
return found.split(';')[0]!;
|
||||
}
|
||||
|
||||
describe('financial disclosure publishing', () => {
|
||||
let app: INestApplication, prisma: PrismaService, passwords: PasswordService;
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = module.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }),
|
||||
);
|
||||
await app.init();
|
||||
prisma = app.get(PrismaService);
|
||||
passwords = app.get(PasswordService);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.authSession.deleteMany();
|
||||
await prisma.userRole.deleteMany();
|
||||
await prisma.adminCredential.deleteMany();
|
||||
await prisma.staffProfile.deleteMany();
|
||||
});
|
||||
afterAll(() => app.close());
|
||||
async function admin() {
|
||||
const username = `finance.${crypto.randomUUID()}`;
|
||||
const role = await prisma.role.findUniqueOrThrow({ where: { code: 'ADMIN' } });
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
publicId: crypto.randomUUID(),
|
||||
userType: 'STAFF',
|
||||
staffProfile: { create: { displayName: '财务公开管理员' } },
|
||||
adminCredential: {
|
||||
create: { username, passwordHash: await passwords.hash('correct horse battery staple') },
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.userRole.create({ data: { userId: user.id, roleId: role.id } });
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/auth/login')
|
||||
.set('origin', ORIGIN)
|
||||
.send({ username, password: 'correct horse battery staple' })
|
||||
.expect(201);
|
||||
return sessionCookie(response);
|
||||
}
|
||||
|
||||
it('keeps drafts private, sanitizes rich text, publishes and audits the document', async () => {
|
||||
const cookie = await admin();
|
||||
const created = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/financial-disclosures')
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
title: '2026年度财务公开',
|
||||
summary: '年度财务公开说明',
|
||||
richText: '<h2>年度说明</h2><p onclick="alert(1)">公开正文</p><script>alert(1)</script>',
|
||||
isPinned: true,
|
||||
sortOrder: 10,
|
||||
})
|
||||
.expect(201);
|
||||
const disclosure =
|
||||
body<ApiSuccessResponse<FinancialDisclosureDetailResponse>>(created).data.disclosure;
|
||||
expect(disclosure.richText).not.toMatch(/script|onclick/);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/api/v1/financial-disclosures/${disclosure.publicId}`)
|
||||
.expect(404);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/financial-disclosures/${disclosure.publicId}/transition`)
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
|
||||
.expect(201);
|
||||
const listed = await request(app.getHttpServer() as Server)
|
||||
.get('/api/v1/financial-disclosures?page=1&pageSize=20')
|
||||
.expect(200);
|
||||
expect(
|
||||
body<ApiSuccessResponse<PaginatedFinancialDisclosuresResponse>>(listed).data.items,
|
||||
).toHaveLength(1);
|
||||
expect(await prisma.financialDisclosureStatusHistory.count()).toBe(2);
|
||||
expect(await prisma.auditLog.count({ where: { resourceType: 'FINANCIAL_DISCLOSURE' } })).toBe(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects edits after publishing and hides archived documents', async () => {
|
||||
const cookie = await admin(),
|
||||
requestId = crypto.randomUUID();
|
||||
const created = await request(app.getHttpServer() as Server)
|
||||
.post('/api/v1/admin/financial-disclosures')
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({
|
||||
clientRequestId: requestId,
|
||||
title: '专项财务公开',
|
||||
richText: '<p>专项公开正文内容</p>',
|
||||
})
|
||||
.expect(201);
|
||||
const item =
|
||||
body<ApiSuccessResponse<FinancialDisclosureDetailResponse>>(created).data.disclosure;
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/financial-disclosures/${item.publicId}/transition`)
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'PUBLISHED' })
|
||||
.expect(201);
|
||||
const rejected = await request(app.getHttpServer() as Server)
|
||||
.patch(`/api/v1/admin/financial-disclosures/${item.publicId}`)
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({
|
||||
expectedRevision: item.revision + 1,
|
||||
title: item.title,
|
||||
richText: '<p>覆盖已发布内容</p>',
|
||||
})
|
||||
.expect(409);
|
||||
expect(body<ApiErrorResponse>(rejected).error.code).toBe('INVALID_STATE_TRANSITION');
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/api/v1/admin/financial-disclosures/${item.publicId}/transition`)
|
||||
.set('origin', ORIGIN)
|
||||
.set('cookie', cookie)
|
||||
.send({ clientRequestId: crypto.randomUUID(), targetStatus: 'ARCHIVED' })
|
||||
.expect(201);
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/api/v1/financial-disclosures/${item.publicId}`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,8 @@ describe('Phase 4 follow-up closure', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
await prisma.notificationPreference.deleteMany();
|
||||
|
||||
@@ -70,6 +70,8 @@ describe('Phase 5 projects, activities and volunteers', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
throttlerStorage.storage.clear();
|
||||
await prisma.auditLog.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
|
||||
@@ -69,6 +69,8 @@ describe('Phase 2 service applications', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.financialDisclosureStatusHistory.deleteMany();
|
||||
await prisma.financialDisclosure.deleteMany();
|
||||
await prisma.notificationDelivery.deleteMany();
|
||||
await prisma.notification.deleteMany();
|
||||
await prisma.notificationPreference.deleteMany();
|
||||
|
||||
Reference in New Issue
Block a user