feat(finance): add financial disclosure publishing

This commit is contained in:
2026-08-13 17:17:33 +08:00
parent 39a2e53e61
commit 65d23eab4a
49 changed files with 2096 additions and 3 deletions
+3 -1
View File
@@ -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');
+53
View File
@@ -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)
+2
View File
@@ -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,
);
}
}
+2
View File
@@ -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();