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
+2
View File
@@ -11,6 +11,8 @@
},
"dependencies": {
"@lehuo/contracts": "workspace:*",
"dompurify": "^3.4.13",
"mammoth": "^1.12.1",
"pinia": "^3.0.3",
"vue": "^3.5.20",
"vue-router": "^4.5.1"
+11
View File
@@ -17,6 +17,7 @@ import NotificationDeliveriesView from './views/NotificationDeliveriesView.vue';
import StatisticsView from './views/StatisticsView.vue';
import DashboardView from './views/DashboardView.vue';
import AdminIcon from './components/AdminIcon.vue';
import FinancialDisclosuresView from './views/FinancialDisclosuresView.vue';
const auth = useAuthStore();
const form = reactive({ username: '', password: '' });
@@ -36,6 +37,7 @@ const activeView = ref<
| 'content'
| 'notifications'
| 'statistics'
| 'financial-disclosures'
>('dashboard');
onMounted(() => auth.restore());
@@ -171,6 +173,14 @@ async function logout(): Promise<void> {
>
<AdminIcon name="content" />内容发布
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'financial-disclosures' }]"
type="button"
:aria-current="activeView === 'financial-disclosures' ? 'page' : undefined"
@click="activeView = 'financial-disclosures'"
>
<AdminIcon name="content" />财务公开
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'notifications' }]"
type="button"
@@ -253,6 +263,7 @@ async function logout(): Promise<void> {
<ProjectsView v-else-if="activeView === 'projects'" />
<ActivitiesView v-else-if="activeView === 'activities'" />
<ContentView v-else-if="activeView === 'content'" />
<FinancialDisclosuresView v-else-if="activeView === 'financial-disclosures'" />
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
<StatisticsView v-else />
</div>
@@ -0,0 +1,43 @@
import type {
CreateFinancialDisclosureRequest,
FinancialDisclosureDetailResponse,
PaginatedFinancialDisclosuresResponse,
TransitionFinancialDisclosureRequest,
UpdateFinancialDisclosureRequest,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
const query = (value: Record<string, string | number | undefined>) => {
const params = new URLSearchParams();
Object.entries(value).forEach(
([key, item]) => item !== undefined && params.set(key, String(item)),
);
return params.toString();
};
export const fetchFinancialDisclosures = (value: {
page: number;
pageSize: number;
status?: string;
}) =>
apiRequest<PaginatedFinancialDisclosuresResponse>(
`/api/v1/admin/financial-disclosures?${query(value)}`,
);
export const fetchFinancialDisclosure = (id: string) =>
apiRequest<FinancialDisclosureDetailResponse>(`/api/v1/admin/financial-disclosures/${id}`);
export const createFinancialDisclosure = (input: CreateFinancialDisclosureRequest) =>
apiRequest<FinancialDisclosureDetailResponse>('/api/v1/admin/financial-disclosures', {
method: 'POST',
body: JSON.stringify(input),
});
export const updateFinancialDisclosure = (id: string, input: UpdateFinancialDisclosureRequest) =>
apiRequest<FinancialDisclosureDetailResponse>(`/api/v1/admin/financial-disclosures/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
export const transitionFinancialDisclosure = (
id: string,
input: TransitionFinancialDisclosureRequest,
) =>
apiRequest<FinancialDisclosureDetailResponse>(
`/api/v1/admin/financial-disclosures/${id}/transition`,
{ method: 'POST', body: JSON.stringify(input) },
);
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { defaultFinancialSummary, htmlToPlainText } from './financial-document';
describe('financial document helpers', () => {
it('creates a short readable summary from imported HTML', () => {
expect(htmlToPlainText('<h1>年度报告</h1><p>公益项目执行情况</p>')).toBe(
'年度报告公益项目执行情况',
);
expect(defaultFinancialSummary(`<p>${'财'.repeat(130)}</p>`)).toHaveLength(120);
});
});
+17
View File
@@ -0,0 +1,17 @@
export function htmlToPlainText(html: string): string {
return html
.replace(/<br\s*\/?\s*>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;|&#160;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.replace(/\s+/g, ' ')
.trim();
}
export function defaultFinancialSummary(html: string): string {
return htmlToPlainText(html).slice(0, 120);
}
+11
View File
@@ -0,0 +1,11 @@
declare module 'mammoth/mammoth.browser' {
export interface ConversionMessage {
type: 'warning' | 'error';
message: string;
}
export interface ConversionResult {
value: string;
messages: ConversionMessage[];
}
export function convertToHtml(input: { arrayBuffer: ArrayBuffer }): Promise<ConversionResult>;
}
+151
View File
@@ -27,6 +27,157 @@
sans-serif;
}
.financial-workspace {
display: grid;
gap: 20px;
}
.financial-editor-panel {
padding: 24px;
border: 1px solid var(--color-border);
border-radius: 16px;
background: #fff;
box-shadow: var(--shadow-card);
}
.financial-editor-head,
.rich-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.financial-editor-head {
justify-content: flex-end;
margin-bottom: 20px;
}
.import-button {
display: inline-flex;
min-height: 38px;
align-items: center;
padding: 0 16px;
border: 1px solid var(--color-primary);
border-radius: 8px;
color: var(--color-primary);
cursor: pointer;
font-weight: 600;
}
.import-button input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
}
.financial-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px;
gap: 16px;
margin-bottom: 18px;
}
.financial-fields label:first-child,
.financial-fields label:nth-child(2) {
grid-column: 1 / -1;
}
.inline-check {
display: flex;
align-items: center;
gap: 8px;
}
.inline-check input {
width: 18px;
height: 18px;
}
.rich-toolbar {
padding: 10px;
border: 1px solid var(--color-border);
border-bottom: 0;
border-radius: 10px 10px 0 0;
background: #f8fafc;
}
.rich-toolbar button {
min-height: 34px;
padding: 4px 10px;
border: 1px solid #d8dee8;
border-radius: 6px;
background: #fff;
color: #344054;
cursor: pointer;
}
.rich-editor {
min-height: 480px;
padding: 36px;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 0 0 10px 10px;
outline: none;
line-height: 1.8;
}
.rich-editor:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgb(240 90 40 / 12%);
}
.rich-editor table,
.public-rich-text table {
width: 100%;
border-collapse: collapse;
}
.rich-editor th,
.rich-editor td,
.public-rich-text th,
.public-rich-text td {
padding: 8px 10px;
border: 1px solid #d9dde4;
}
.mobile-preview {
width: min(420px, 100%);
min-height: 640px;
margin: 0 auto;
padding: 30px 24px;
border: 10px solid #25282d;
border-radius: 34px;
background: #fff;
box-shadow: 0 18px 48px rgb(23 31 45 / 18%);
}
.preview-label {
margin: -12px 0 24px;
color: #98a2b3;
font-size: 12px;
text-align: center;
}
.preview-summary {
padding: 14px;
border-radius: 10px;
background: #fff5ee;
color: #6d4b3b;
}
.public-rich-text {
overflow-wrap: anywhere;
color: #30343b;
line-height: 1.8;
}
.public-rich-text img {
max-width: 100%;
height: auto;
}
.status-chip {
margin-left: 8px;
padding: 2px 7px;
border-radius: 999px;
background: #fff0e8;
color: #e65322;
font-size: 12px;
}
@media (max-width: 720px) {
.financial-fields {
grid-template-columns: 1fr;
}
.financial-fields label {
grid-column: 1 !important;
}
.rich-editor {
padding: 20px;
}
}
* {
box-sizing: border-box;
}
@@ -0,0 +1,380 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import DOMPurify from 'dompurify';
import {
FINANCIAL_DISCLOSURE_STATUSES,
FINANCIAL_DISCLOSURE_STATUS_LABELS,
type FinancialDisclosureDetail,
type FinancialDisclosureListItem,
type FinancialDisclosureStatus,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
createFinancialDisclosure,
fetchFinancialDisclosure,
fetchFinancialDisclosures,
transitionFinancialDisclosure,
updateFinancialDisclosure,
} from '../api/financial-disclosures';
import { defaultFinancialSummary } from '../financial-document';
const items = ref<FinancialDisclosureListItem[]>([]),
loading = ref(true),
busy = ref(false),
message = ref(''),
editing = ref(false),
previewing = ref(false),
editor = ref<HTMLElement>();
const filter = ref<FinancialDisclosureStatus | ''>(''),
page = ref(1),
total = ref(0),
pageSize = 20;
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const form = reactive({
publicId: '',
revision: 0,
title: '',
summary: '',
richText: '',
isPinned: false,
sortOrder: 0,
});
const errorText = (error: unknown) =>
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: error instanceof Error
? error.message
: '操作失败,请稍后重试';
const sanitizeRichText = (html: string) =>
DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'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',
],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'colspan', 'rowspan', 'style'],
});
async function load(target = 1) {
loading.value = true;
message.value = '';
try {
const result = await fetchFinancialDisclosures({
page: target,
pageSize,
...(filter.value ? { status: filter.value } : {}),
});
items.value = result.items;
page.value = result.page;
total.value = result.total;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function newDocument() {
Object.assign(form, {
publicId: '',
revision: 0,
title: '',
summary: '',
richText: '<h2>财务公开</h2><p>请在此编辑公开内容。</p>',
isPinned: false,
sortOrder: 0,
});
editing.value = true;
previewing.value = false;
await syncEditor();
}
async function edit(item: FinancialDisclosureListItem) {
busy.value = true;
try {
const result = await fetchFinancialDisclosure(item.publicId);
fill(result.disclosure);
editing.value = true;
previewing.value = false;
await syncEditor();
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
function fill(item: FinancialDisclosureDetail) {
Object.assign(form, item);
}
async function syncEditor() {
await nextTick();
if (editor.value) editor.value.innerHTML = form.richText;
}
function command(name: string, value?: string) {
editor.value?.focus();
document.execCommand(name, false, value);
form.richText = editor.value?.innerHTML ?? form.richText;
}
function inputEditor() {
form.richText = editor.value?.innerHTML ?? '';
}
async function importDocx(event: Event) {
const input = event.target as HTMLInputElement,
file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.name.toLowerCase().endsWith('.docx')) {
message.value = '请选择 .docx 格式的 Word 文档。';
return;
}
if (file.size > 10 * 1024 * 1024) {
message.value = 'DOCX 文件不能超过 10MB。';
return;
}
busy.value = true;
try {
const mammoth = await import('mammoth/mammoth.browser');
const result = await mammoth.convertToHtml({ arrayBuffer: await file.arrayBuffer() });
form.richText = sanitizeRichText(result.value);
if (!form.title) form.title = file.name.replace(/\.docx$/i, '').slice(0, 200);
if (!form.summary) form.summary = defaultFinancialSummary(result.value);
await syncEditor();
message.value = result.messages.length
? '文档已导入,部分复杂样式已简化,请预览确认。'
: '文档已导入,可继续编辑和排版。';
} catch {
message.value = 'Word 文档无法解析,请确认文件未损坏。';
} finally {
busy.value = false;
}
}
async function save() {
form.richText = sanitizeRichText(editor.value?.innerHTML ?? form.richText);
if (!form.title.trim() || !defaultFinancialSummary(form.richText)) {
message.value = '请填写标题和正文。';
return;
}
busy.value = true;
try {
const payload = {
title: form.title.trim(),
...(form.summary.trim() ? { summary: form.summary.trim() } : {}),
richText: form.richText,
isPinned: form.isPinned,
sortOrder: Number(form.sortOrder) || 0,
};
const result = form.publicId
? await updateFinancialDisclosure(form.publicId, {
expectedRevision: form.revision,
...payload,
})
: await createFinancialDisclosure({ clientRequestId: crypto.randomUUID(), ...payload });
fill(result.disclosure);
message.value = '财务公开草稿已保存。';
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function transition(item: FinancialDisclosureListItem) {
const targetStatus = item.status === 'DRAFT' ? 'PUBLISHED' : 'ARCHIVED';
if (
!window.confirm(
targetStatus === 'PUBLISHED'
? `确认发布“${item.title}”?发布后小程序将立即可见。`
: `确认归档“${item.title}”?`,
)
)
return;
busy.value = true;
try {
await transitionFinancialDisclosure(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
});
message.value = targetStatus === 'PUBLISHED' ? '文档已发布到小程序。' : '文档已归档。';
editing.value = false;
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
onMounted(() => load());
</script>
<template>
<main class="content-area">
<header class="page-header">
<div>
<h1>财务公开</h1>
<p>导入 Word 文档,调整排版后直接发布到小程序。</p>
</div>
<button class="primary-button compact-button" type="button" @click="newDocument">
新建文档
</button>
</header>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="editing" class="financial-workspace">
<div class="financial-editor-panel">
<div class="financial-editor-head">
<button class="secondary-button compact-button" type="button" @click="editing = false">
返回列表</button
><label class="import-button"
>导入 DOCX<input
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
@change="importDocx" /></label
><button
class="secondary-button compact-button"
type="button"
@click="previewing = !previewing"
>
{{ previewing ? '返回编辑' : '小程序预览' }}
</button>
</div>
<template v-if="!previewing"
><div class="financial-fields">
<label>标题<input v-model.trim="form.title" maxlength="200" /></label
><label
>摘要(选填)<textarea v-model.trim="form.summary" maxlength="500"></textarea></label
><label class="inline-check"
><input v-model="form.isPinned" type="checkbox" />置顶显示</label
><label
>排序<input v-model.number="form.sortOrder" type="number" min="-9999" max="9999"
/></label>
</div>
<div class="rich-toolbar" aria-label="排版工具栏">
<button type="button" @click="command('formatBlock', 'h2')">标题</button
><button type="button" @click="command('formatBlock', 'p')">正文</button
><button type="button" @click="command('bold')"><strong>加粗</strong></button
><button type="button" @click="command('underline')"><u>下划线</u></button
><button type="button" @click="command('insertUnorderedList')">项目符号</button
><button type="button" @click="command('insertOrderedList')">编号</button
><button type="button" @click="command('justifyLeft')">左对齐</button
><button type="button" @click="command('justifyCenter')">居中</button
><button type="button" @click="command('undo')">撤销</button
><button type="button" @click="command('redo')">重做</button>
</div>
<div
ref="editor"
class="rich-editor"
contenteditable="true"
role="textbox"
aria-label="财务公开正文"
aria-multiline="true"
@input="inputEditor"
></div
></template>
<article v-else class="mobile-preview">
<p class="preview-label">小程序阅读效果预览</p>
<h1>{{ form.title }}</h1>
<p v-if="form.summary" class="preview-summary">{{ form.summary }}</p>
<div class="public-rich-text" v-html="form.richText"></div>
</article>
<div class="form-actions">
<button
class="primary-button compact-button"
:disabled="busy"
type="button"
@click="save"
>
{{ busy ? '保存中…' : '保存草稿' }}
</button>
</div>
</div>
</section>
<template v-else
><form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label>状态</label
><select v-model="filter">
<option value="">全部</option>
<option v-for="status in FINANCIAL_DISCLOSURE_STATUSES" :key="status" :value="status">
{{ FINANCIAL_DISCLOSURE_STATUS_LABELS[status] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<section v-if="loading" class="panel">正在加载财务公开文档…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无财务公开文档</h2>
<p>可以新建空白文档,也可以导入本地 DOCX。</p>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>标题</th>
<th>状态</th>
<th>发布/更新</th>
<th>排序</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>
<strong>{{ item.title }}</strong
><span v-if="item.isPinned" class="status-chip">置顶</span><br />{{
item.summary || '暂无摘要'
}}
</td>
<td>{{ FINANCIAL_DISCLOSURE_STATUS_LABELS[item.status] }}</td>
<td>{{ new Date(item.publishedAt || item.updatedAt).toLocaleString('zh-CN') }}</td>
<td>{{ item.sortOrder }}</td>
<td>
<button
v-if="item.status === 'DRAFT'"
class="link-button"
:disabled="busy"
@click="edit(item)"
>
编辑</button
><button
v-if="item.status !== 'ARCHIVED'"
class="link-button"
:disabled="busy"
@click="transition(item)"
>
{{ item.status === 'DRAFT' ? '发布' : '归档' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
><span>第 {{ page }} / {{ totalPages }} 页</span
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
</div>
</section></template
>
</main>
</template>
+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();
+3 -1
View File
@@ -17,7 +17,9 @@
"pages/volunteer-center/volunteer-center",
"pages/content/content",
"pages/content-detail/content-detail",
"pages/notifications/notifications"
"pages/notifications/notifications",
"pages/financial-disclosures/financial-disclosures",
"pages/financial-disclosure-detail/financial-disclosure-detail"
],
"window": {
"backgroundColor": "#F7F5F2",
@@ -0,0 +1 @@
{ "navigationBarTitleText": "财务公开详情" }
@@ -0,0 +1,24 @@
import type { FinancialDisclosureDetail } from '@lehuo/contracts';
import { fetchFinancialDisclosure } from '../../services/financial-disclosures';
Page({
data: { loading: true, message: '', disclosure: null as FinancialDisclosureDetail | null },
onLoad(options) {
const id = String(options['publicId'] || '');
if (!id) {
this.setData({ loading: false, message: '缺少财务公开文档标识,请返回重试' });
return;
}
void this.load(id);
},
async load(id: string) {
this.setData({ loading: true, message: '' });
try {
const result = await fetchFinancialDisclosure(id);
this.setData({ disclosure: result.disclosure });
} catch (error) {
this.setData({ message: error instanceof Error ? error.message : '财务公开内容加载失败' });
} finally {
this.setData({ loading: false });
}
},
});
@@ -0,0 +1 @@
<view class="page disclosure-detail-page"><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{message}}" class="state-card"><text class="state-title">文档暂时无法查看</text><text class="state-text">{{message}}</text></view><view wx:elif="{{disclosure}}" class="disclosure-paper"><text class="disclosure-title">{{disclosure.title}}</text><text wx:if="{{disclosure.summary}}" class="disclosure-summary">{{disclosure.summary}}</text><view class="disclosure-meta"><text>发布时间</text><text>{{disclosure.publishedAt}}</text></view><scroll-view class="document-scroll" scroll-x="true"><rich-text class="disclosure-rich-text" nodes="{{disclosure.richText}}" selectable="true"></rich-text></scroll-view><text class="disclosure-note">本页面内容由乐活公益管理后台正式发布。</text></view></view>
@@ -0,0 +1,55 @@
@import '../../app.wxss';
.disclosure-paper {
padding: 38rpx 32rpx;
border-radius: 26rpx;
background: #fff;
box-shadow: 0 10rpx 30rpx rgba(38, 42, 48, 0.07);
}
.disclosure-title,
.disclosure-summary,
.disclosure-note {
display: block;
}
.disclosure-title {
color: #25292f;
font-size: 42rpx;
font-weight: 700;
line-height: 1.4;
}
.disclosure-summary {
margin-top: 22rpx;
padding: 22rpx;
border-radius: 16rpx;
background: #fff5ef;
color: #684d40;
font-size: 28rpx;
line-height: 1.7;
}
.disclosure-meta {
display: flex;
justify-content: space-between;
margin: 28rpx 0;
padding-bottom: 22rpx;
border-bottom: 1rpx solid #eceef1;
color: #8c929a;
font-size: 24rpx;
}
.document-scroll {
width: 100%;
}
.disclosure-rich-text {
display: block;
min-width: 100%;
color: #333840;
font-size: 30rpx;
line-height: 1.85;
overflow-wrap: anywhere;
}
.disclosure-note {
margin-top: 36rpx;
padding-top: 24rpx;
border-top: 1rpx solid #eceef1;
color: #9a9fa7;
font-size: 23rpx;
text-align: center;
}
@@ -0,0 +1 @@
{ "navigationBarTitleText": "财务公开" }
@@ -0,0 +1,26 @@
import type { FinancialDisclosureListItem } from '@lehuo/contracts';
import { fetchFinancialDisclosures } from '../../services/financial-disclosures';
Page({
data: { loading: true, message: '', items: [] as FinancialDisclosureListItem[] },
onLoad() {
void this.load();
},
async load() {
this.setData({ loading: true, message: '' });
try {
const result = await fetchFinancialDisclosures();
this.setData({ items: result.items });
} catch (error) {
this.setData({ message: error instanceof Error ? error.message : '财务公开内容加载失败' });
} finally {
this.setData({ loading: false });
}
},
open(event: WechatMiniprogram.BaseEvent) {
const id = String(event.currentTarget.dataset['id'] || '');
if (id)
void wx.navigateTo({
url: `/pages/financial-disclosure-detail/financial-disclosure-detail?publicId=${encodeURIComponent(id)}`,
});
},
});
@@ -0,0 +1 @@
<view class="page financial-list-page"><view class="financial-hero"><text class="financial-hero__eyebrow">公开 · 透明 · 可查阅</text><text class="financial-hero__title">财务公开</text><text class="financial-hero__copy">查看乐活公益经管理端正式发布的财务公开文档。</text></view><view wx:if="{{loading}}" class="state-card">正在加载…</view><view wx:elif="{{message}}" class="state-card"><text class="state-title">暂时无法加载</text><text class="state-text">{{message}}</text><button class="secondary-button" bindtap="load">重新加载</button></view><view wx:elif="{{!items.length}}" class="state-card"><text class="state-title">暂无公开文档</text><text class="state-text">财务公开文档发布后会展示在这里。</text></view><button wx:for="{{items}}" wx:key="publicId" class="financial-card" data-id="{{item.publicId}}" bindtap="open"><view class="financial-card__top"><text wx:if="{{item.isPinned}}" class="pin-tag">置顶</text><text class="financial-card__date">{{item.publishedAt}}</text></view><text class="financial-card__title">{{item.title}}</text><text class="financial-card__summary">{{item.summary || '点击查看完整公开内容'}}</text><text class="financial-card__link">查看详情 ›</text></button></view>
@@ -0,0 +1,74 @@
@import '../../app.wxss';
.financial-hero {
padding: 40rpx 34rpx;
border-radius: 28rpx;
background: linear-gradient(135deg, #ff6b35, #ff9b5f);
color: #fff;
box-shadow: 0 18rpx 40rpx rgba(240, 90, 40, 0.18);
}
.financial-hero text,
.financial-card text {
display: block;
}
.financial-hero__eyebrow {
font-size: 24rpx;
opacity: 0.86;
}
.financial-hero__title {
margin-top: 8rpx;
font-size: 46rpx;
font-weight: 700;
}
.financial-hero__copy {
margin-top: 14rpx;
line-height: 1.65;
}
.financial-card {
width: 100%;
margin-top: 24rpx;
padding: 30rpx;
border: 0;
border-radius: 24rpx;
background: #fff;
text-align: left;
box-shadow: 0 8rpx 28rpx rgba(38, 42, 48, 0.07);
}
.financial-card::after {
border: 0;
}
.financial-card__top {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 38rpx;
}
.pin-tag {
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: #fff0e8;
color: #ee5b27;
font-size: 22rpx;
}
.financial-card__date {
margin-left: auto;
color: #969ba3;
font-size: 22rpx;
}
.financial-card__title {
margin-top: 16rpx;
color: #262a30;
font-size: 34rpx;
font-weight: 700;
}
.financial-card__summary {
margin-top: 14rpx;
color: #676d76;
font-size: 27rpx;
line-height: 1.65;
}
.financial-card__link {
margin-top: 20rpx;
color: #f05a28;
font-size: 26rpx;
font-weight: 600;
}
@@ -43,6 +43,9 @@ Page({
organization() {
void wx.navigateTo({ url: '/pages/content/content?type=ORGANIZATION_INTRO' });
},
financialDisclosures() {
void wx.navigateTo({ url: '/pages/financial-disclosures/financial-disclosures' });
},
openProject(event: WechatMiniprogram.BaseEvent) {
void wx.navigateTo({
url: `/pages/project-detail/project-detail?publicId=${event.currentTarget.dataset['id'] as string}`,
@@ -10,6 +10,6 @@
<view wx:if="{{!activities.length}}" class="empty-strip">暂无可查看活动</view>
<button wx:for="{{activities}}" wx:key="publicId" class="feature-card" data-id="{{item.publicId}}" bindtap="openActivity"><view class="feature-card__art green">♧</view><view class="feature-card__body"><text class="feature-card__tag">{{item.statusLabel}}</text><text class="feature-card__title">{{item.title}}</text><text class="feature-card__copy">{{item.summary}}</text><text class="feature-card__meta">已报名 {{item.activeRegistrationCount}} / {{item.capacity === null ? '不限' : item.capacity}}</text></view></button>
<view class="section-heading"><text class="section-title">了解乐活</text><text class="section-note">所有内容均经审核发布</text></view>
<view class="link-grid"><button bindtap="content"><text class="link-grid__icon orange">闻</text><text>公益动态</text><text>›</text></button><button bindtap="organization"><text class="link-grid__icon blue">乐</text><text>机构介绍</text><text>›</text></button></view>
<view class="link-grid"><button bindtap="content"><text class="link-grid__icon orange">闻</text><text>公益动态</text><text>›</text></button><button bindtap="organization"><text class="link-grid__icon blue">乐</text><text>机构介绍</text><text>›</text></button><button bindtap="financialDisclosures"><text class="link-grid__icon green">财</text><text>财务公开</text><text>›</text></button></view>
</block>
</view>
@@ -0,0 +1,13 @@
import type {
FinancialDisclosureDetailResponse,
PaginatedFinancialDisclosuresResponse,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
export const fetchFinancialDisclosures = () =>
apiRequest<PaginatedFinancialDisclosuresResponse>(
'/api/v1/financial-disclosures?page=1&pageSize=50',
);
export const fetchFinancialDisclosure = (id: string) =>
apiRequest<FinancialDisclosureDetailResponse>(
`/api/v1/financial-disclosures/${encodeURIComponent(id)}`,
);
+2
View File
@@ -13,6 +13,8 @@ const allowedTags = new Set([
'input',
'label',
'picker',
'rich-text',
'scroll-view',
'switch',
'text',
'textarea',
+8
View File
@@ -452,3 +452,11 @@ V1:
- 志愿服务时长
不得用手工填写数字覆盖真实统计结果。
---
## 17. 财务公开文档
经用户批准,财务公开采用文档发布方式,不建设财务核算或在线募捐系统。工作人员在本地完成文档后,可在管理后台导入 `.docx`、继续编辑基础富文本排版、预览并发布;公众在小程序“公益”页查看已发布文档列表和详情。
最小字段为标题、可选摘要、富文本正文、草稿/已发布/已归档状态、置顶、排序、revision、发布时间和创建/更新人员。首版不包含支付、捐赠订单、银行流水同步、结构化收支录入、金额统计或复杂审批。
+15
View File
@@ -470,3 +470,18 @@ Phase 7 不建立可人工修改的统计快照;九项 V1 指标直接从申
6. 所有枚举在 contracts 中有单一事实来源。
7. migration 不允许 destructive change 直接上线。
8. 删除列采用“停用 → 迁移 → 验证 → 后续版本删除”。
---
## 23. financial_disclosures
- id / public_id(随机 UUID)
- client_request_id / request_fingerprint(创建幂等)
- title / summary nullable
- rich_text(后端白名单清洗后的富文本)
- status:DRAFT / PUBLISHED / ARCHIVED
- is_pinned / sort_order / revision
- published_at nullable
- created_by / updated_by / timestamps
`financial_disclosure_status_history` 只追加保存创建、发布和归档事实,请求 UUID 全局唯一。已发布文档禁止覆盖编辑,只能归档;草稿编辑使用 revision 乐观锁。
+19
View File
@@ -287,3 +287,22 @@ Phase 0 提供:
```
健康检查不得返回配置值、数据库连接串、密钥或其他敏感信息。业务 API 仍统一使用 `/api/v1` 前缀;基础设施健康检查不属于业务 API。
---
## 12. 财务公开文档 API
公众接口:
- `GET /api/v1/financial-disclosures`
- `GET /api/v1/financial-disclosures/:publicId`
只返回 `PUBLISHED` 文档,草稿和归档文档按不存在处理。
管理端接口:
- `GET/POST /api/v1/admin/financial-disclosures`
- `GET/PATCH /api/v1/admin/financial-disclosures/:publicId`
- `POST /api/v1/admin/financial-disclosures/:publicId/transition`
创建、发布和归档使用客户端 UUID 幂等;编辑只允许草稿且要求 expectedRevision。状态为 `DRAFT → PUBLISHED → ARCHIVED`,状态变化与历史、审计事务一致。DOCX 在管理端浏览器转换为富文本,API 只接受并白名单清洗富文本,不执行 Office 文件。
+7
View File
@@ -283,3 +283,10 @@
- 只有 `APPROVED` 志愿者可报名;活动行锁、有效报名计数和数据库唯一约束共同防止并发超额与重复报名。
- 报名审核、取消、签到、缺席和服务分钟均写不可变历史与审计。服务分钟只允许对已通过、已签到且活动已完成的记录核定,且不得超过活动持续时间。
- 公益项目和活动公开接口只返回公开状态内容,不返回志愿者名单或服务对象资料。封面未配置对象存储时不生成假地址或假成功。
## 财务公开富文本安全(2026-08-13)
- DOCX 仅在管理端浏览器转换,不由 API 执行或解析 Office 宏;首版不持久化原 Word 文件。
- 管理端导入和预览先经 DOMPurify 清洗,API 保存前再次使用独立白名单清洗;禁止脚本、事件属性、iframe、非 HTTPS 链接和非 HTTPS 图片。
- 只有管理员与超级管理员默认获得财务公开查看、编辑和发布权限;公众只能访问已发布文档。
- 已发布文档禁止原地覆盖编辑,归档后不可公开;发布和归档写入不可变历史与审计。
+8
View File
@@ -167,3 +167,11 @@ P2:5. 公益项目 6. 公益活动 7. 我要做公益
- 生成的人物图片均为虚构品牌场景素材,仅作服务类别与项目视觉,不作为真实服务对象、服务现场或项目成果证据;素材已压缩进入小程序本地包。
- 捐赠支付、自动捐赠、捐赠记录/金额、物资订单、志愿者培训/证书/团队不展示入口;代码只保留禁用的集中能力登记和组件/导航扩展边界。
- 图片中的金额、满意度、捐赠人数等示例值不得复制;所有运行时数字必须来自已批准接口,缺少事实时使用明确空状态。
---
## 13. 财务公开文档体验(2026-08-13)
- 管理端新增独立“财务公开”导航,支持空白新建、10MB 以内 DOCX 导入、标题/摘要、基础标题与段落、加粗、下划线、列表、对齐、撤销/重做和接近小程序宽度的预览。
- Word 的复杂浮动对象、页眉页脚、分页和高级样式允许在导入时简化,发布前必须人工预览确认;首版重点保障段落、标题、列表和表格。
- 小程序“公益 → 了解乐活”新增财务公开入口;列表展示置顶、标题、摘要和发布时间,详情使用 rich-text 展示,宽表可横向滚动,加载失败可重试。
+10
View File
@@ -155,3 +155,13 @@ UAT 不只让开发人员自测。
- 微信小程序审核所需材料就绪;
- 监控和告警正常;
- 可回滚上一版本。
---
## 8. 财务公开验收
- DOCX 可在管理端本地导入,基础标题、段落、列表和表格可继续编辑并预览。
- 危险 HTML、脚本、事件属性、iframe 和非 HTTPS 外链不能进入公开正文。
- 未登录或无权限人员不能访问管理接口;草稿和归档文档不能从公众接口获取。
- 草稿可编辑并使用 revision 防并发覆盖;已发布文档不能覆盖编辑,只能归档。
- 创建、发布和归档幂等,状态历史和审计完整;小程序列表、详情、空状态和失败状态可用。
+14
View File
@@ -6,6 +6,20 @@
2026-08-13
任务:财务公开文档发布模块。
完成:按用户确认的简化范围新增独立财务公开模块。管理后台支持新建空白文档、导入 10MB 内 `.docx`、编辑标题/摘要/置顶/排序、基础富文本排版、小程序宽度预览、保存草稿、直接发布和归档;小程序“公益”页增加财务公开入口,完成公开列表、详情、宽表横向阅读、空状态和错误状态。新增共享契约、`financial_disclosures` 与只追加状态历史、三项独立权限、随机公开 ID、创建/状态幂等、revision 乐观锁、发布/归档事务审计,以及管理端 DOMPurify + API sanitize-html 双层富文本白名单清洗。已发布内容禁止原地覆盖编辑。
范围:这是用户明确批准的新增模块,但不改变 D-017 对支付和公开募捐的排除。首版不保存原 DOCX、不提供结构化收支字段、财务核算、复杂审批、支付、捐赠订单、银行流水同步或金额统计。Word 复杂浮动对象、页眉页脚和高级分页样式可能简化,发布前必须人工预览。
验证:格式化、Lint、全仓类型检查和 28 个单元测试通过;新增 migration 已部署到专用 `lehuo_test`,共 9 个 migration;36 个真实 MySQL 集成测试全部通过,覆盖草稿隔离、危险富文本清洗、发布可见、发布后禁止覆盖、归档隐藏、状态历史和审计;小程序页面门禁通过 20 个页面,运行时 workspace 导入门禁通过;全仓生产构建通过。新增 D-018。
遗留:仍需在微信开发者工具清缓存重新编译并进行真机财务公开列表/详情视觉验收;需用机构实际 DOCX 样本确认复杂表格和排版还原度。真实微信 AppID/AppSecret、合法 HTTPS 域名和生产环境配置仍按既有计划后期提供。
---
2026-08-13
任务:微信真机二级页面白屏修复。
根因:服务申请和协作任务详情/服务记录页面在运行时从 workspace 包 `@lehuo/contracts` 导入业务常量。现有小程序工程没有生成 `miniprogram_npm`,常规 TypeScript 检查会擦除类型导入并通过,但真机无法解析运行时 workspace 模块,导致页面脚本在 `Page()` 注册前终止并显示空白。另发现内容详情使用非小程序原生 `article` 标签、项目详情使用 `strong` 标签,以及详情页缺少路由参数时可能持续处于加载状态。
+14
View File
@@ -209,6 +209,20 @@ V1 统计直接从业务事实表计算,不建立可人工覆盖的统计表
---
## D-018 财务公开采用受限富文本文档发布
状态:ACCEPTED
决定:
财务公开作为独立文档发布模块,由管理人员在本地完成文档后导入管理后台、调整基础富文本排版、预览并直接发布,小程序只读查看。状态采用 `DRAFT → PUBLISHED → ARCHIVED`;已发布正文禁止覆盖编辑,创建、发布和归档必须幂等并写不可变状态历史及审计。
DOCX 仅在管理端浏览器转换,API 保存前对富文本执行服务端白名单清洗。首版不保存原 Word 文件,不建设结构化财务核算、复杂审核、在线募捐、支付、捐赠订单、银行流水同步或金额统计。
原因:
满足机构以现有本地文档进行公开的真实工作方式,降低录入成本,并通过双层富文本清洗、后端权限和发布状态隔离保证公众展示安全;同时避免把轻量公开需求扩大成财务或支付系统。
---
## 待确认建议
Codex 若有会改变已批准架构的建议,只能写在这里,不得直接实施:
+3
View File
@@ -48,6 +48,9 @@ export const PERMISSION_CODES = {
CONTENT_READ: 'content:read',
CONTENT_MANAGE: 'content:manage',
CONTENT_REVIEW: 'content:review',
FINANCIAL_DISCLOSURES_READ: 'financial_disclosures:read',
FINANCIAL_DISCLOSURES_MANAGE: 'financial_disclosures:manage',
FINANCIAL_DISCLOSURES_PUBLISH: 'financial_disclosures:publish',
NOTIFICATIONS_MANAGE: 'notifications:manage',
STATISTICS_READ: 'statistics:read',
EXPORTS_CREATE: 'exports:create',
@@ -0,0 +1,55 @@
export const FINANCIAL_DISCLOSURE_STATUSES = ['DRAFT', 'PUBLISHED', 'ARCHIVED'] as const;
export type FinancialDisclosureStatus = (typeof FINANCIAL_DISCLOSURE_STATUSES)[number];
export const FINANCIAL_DISCLOSURE_STATUS_LABELS: Record<FinancialDisclosureStatus, string> = {
DRAFT: '草稿',
PUBLISHED: '已发布',
ARCHIVED: '已归档',
};
export interface FinancialDisclosureListItem {
publicId: string;
title: string;
summary: string | null;
status: FinancialDisclosureStatus;
isPinned: boolean;
sortOrder: number;
revision: number;
publishedAt: string | null;
updatedAt: string;
}
export interface FinancialDisclosureDetail extends FinancialDisclosureListItem {
richText: string;
}
export interface PaginatedFinancialDisclosuresResponse {
items: FinancialDisclosureListItem[];
page: number;
pageSize: number;
total: number;
}
export interface FinancialDisclosureDetailResponse {
disclosure: FinancialDisclosureDetail;
}
export interface CreateFinancialDisclosureRequest {
clientRequestId: string;
title: string;
summary?: string;
richText: string;
isPinned?: boolean;
sortOrder?: number;
}
export interface UpdateFinancialDisclosureRequest {
expectedRevision: number;
title: string;
summary?: string;
richText: string;
isPinned?: boolean;
sortOrder?: number;
}
export interface TransitionFinancialDisclosureRequest {
clientRequestId: string;
targetStatus: 'PUBLISHED' | 'ARCHIVED';
}
+14
View File
@@ -27,6 +27,20 @@ export {
SERVICE_CATEGORY_LABELS,
} from './service-applications.js';
export type { StatisticsOverview } from './statistics.js';
export {
FINANCIAL_DISCLOSURE_STATUSES,
FINANCIAL_DISCLOSURE_STATUS_LABELS,
} from './financial-disclosures.js';
export type {
FinancialDisclosureStatus,
FinancialDisclosureListItem,
FinancialDisclosureDetail,
PaginatedFinancialDisclosuresResponse,
FinancialDisclosureDetailResponse,
CreateFinancialDisclosureRequest,
UpdateFinancialDisclosureRequest,
TransitionFinancialDisclosureRequest,
} from './financial-disclosures.js';
export {
CONTENT_POST_STATUSES,
CONTENT_POST_STATUS_LABELS,
+336
View File
@@ -6,6 +6,7 @@ settings:
overrides:
js-yaml: 5.2.2
lodash: 4.18.1
importers:
@@ -35,6 +36,12 @@ importers:
'@lehuo/contracts':
specifier: workspace:*
version: link:../../packages/contracts
dompurify:
specifier: ^3.4.13
version: 3.4.13
mammoth:
specifier: ^1.12.1
version: 1.12.1
pinia:
specifier: ^3.0.3
version: 3.0.3(typescript@5.9.2)(vue@3.5.41(typescript@5.9.2))
@@ -114,6 +121,9 @@ importers:
rxjs:
specifier: ^7.8.2
version: 7.8.2
sanitize-html:
specifier: ^2.17.6
version: 2.17.6
devDependencies:
'@nestjs/testing':
specifier: ^11.1.6
@@ -127,6 +137,9 @@ importers:
'@types/node':
specifier: ^24.3.0
version: 24.13.3
'@types/sanitize-html':
specifier: ^2.16.1
version: 2.16.1
'@types/supertest':
specifier: ^6.0.3
version: 6.0.3
@@ -1032,6 +1045,9 @@ packages:
'@types/react@19.2.18':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
'@types/sanitize-html@2.16.1':
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
@@ -1044,6 +1060,9 @@ packages:
'@types/supertest@6.0.3':
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
@@ -1231,6 +1250,10 @@ packages:
'@vue/shared@3.5.41':
resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==}
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1281,6 +1304,9 @@ packages:
resolution: {integrity: sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==}
engines: {node: '>=16.17.0'}
argparse@1.0.3:
resolution: {integrity: sha512-W3ljhM0j9lduyssD8+dPR0P0HWCSCGrflhsgIVFzNQpx8TDQGN/dcqcb0hOuOWsf0TNZ8OIVgKXC7Rb2vpUp+Q==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1305,12 +1331,18 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-result@2.10.0:
resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==}
birpc@2.3.0:
resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==}
bluebird@3.4.7:
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'}
@@ -1450,6 +1482,9 @@ packages:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
@@ -1518,6 +1553,9 @@ packages:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1538,6 +1576,10 @@ packages:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
engines: {node: '>=16.0.0'}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
@@ -1562,10 +1604,48 @@ packages:
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
dingbat-to-unicode@1.0.1:
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
dom-serializer@3.1.1:
resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==}
engines: {node: '>=20.19.0'}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domelementtype@3.0.0:
resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==}
engines: {node: '>=20.19.0'}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domhandler@6.0.1:
resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==}
engines: {node: '>=20.19.0'}
dompurify@3.4.13:
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
domutils@4.0.2:
resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==}
engines: {node: '>=20.19.0'}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
duck@0.1.12:
resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1596,10 +1676,18 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
entities@4.2.0:
resolution: {integrity: sha512-wEJa03bJgqEwPnkUqYdgmcfUXfm6+4hePQhntIvRy/1/+C4dFuhYHsgKBRjbQ6OWBh42P+VhAoCDO77DUh0e/Q==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
env-paths@3.0.0:
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -1862,6 +1950,13 @@ packages:
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
htmlparser2@12.0.0:
resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==}
engines: {node: '>=20.19.0'}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -1885,6 +1980,9 @@ packages:
resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.2.1:
resolution: {integrity: sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==}
engines: {node: '>=6'}
@@ -1916,6 +2014,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -1926,6 +2028,9 @@ packages:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -1960,9 +2065,15 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
launder@1.7.1:
resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -1970,6 +2081,9 @@ packages:
libphonenumber-js@1.13.10:
resolution: {integrity: sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
load-esm@1.0.3:
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
engines: {node: '>=13.2.0'}
@@ -1987,6 +2101,9 @@ packages:
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
lop@0.4.2:
resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -2011,6 +2128,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mammoth@1.12.1:
resolution: {integrity: sha512-nCH9KKjWi3jQ+i8bUKs7k1yrXtSEGpWgF8IYkzsFMcbn+5S6l4bZEBbyx2hOQErFiXPuAs9RPa6qjXVxhyx/8g==}
engines: {node: '>=12.0.0'}
hasBin: true
mariadb@3.4.5:
resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==}
engines: {node: '>= 14'}
@@ -2142,6 +2264,9 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
option@0.2.4:
resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2161,10 +2286,16 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@1.0.2:
resolution: {integrity: sha512-qCaDHl8pk5JSbz5k1Bi5zTZf430DUuV13pmcFEHHan4fW++WIwm2HXUirpzfciBRyFUN+eeZW11bfIM/Y4Zeog==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -2176,6 +2307,10 @@ packages:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -2249,6 +2384,9 @@ packages:
typescript:
optional: true
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
@@ -2287,6 +2425,9 @@ packages:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@@ -2340,6 +2481,9 @@ packages:
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -2350,6 +2494,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sanitize-html@2.17.6:
resolution: {integrity: sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==}
engines: {node: '>=22.12.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -2369,6 +2517,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -2414,6 +2565,9 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
@@ -2443,6 +2597,9 @@ packages:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -2563,6 +2720,9 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
underscore@1.13.8:
resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
@@ -2712,6 +2872,10 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xmlbuilder@10.1.1:
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
engines: {node: '>=4.0'}
zeptomatch@2.1.0:
resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==}
@@ -3534,6 +3698,10 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/sanitize-html@2.16.1':
dependencies:
htmlparser2: 10.1.0
'@types/send@1.2.1':
dependencies:
'@types/node': 24.13.3
@@ -3555,6 +3723,9 @@ snapshots:
'@types/methods': 1.1.4
'@types/superagent': 8.1.11
'@types/trusted-types@2.0.7':
optional: true
'@types/validator@13.15.10': {}
'@types/wechat-miniprogram@3.4.10': {}
@@ -3871,6 +4042,8 @@ snapshots:
'@vue/shared@3.5.41': {}
'@xmldom/xmldom@0.8.13': {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -3921,6 +4094,11 @@ snapshots:
node-addon-api: 8.9.1
node-gyp-build: 4.8.4
argparse@1.0.3:
dependencies:
lodash: 4.18.1
sprintf-js: 1.0.3
argparse@2.0.1: {}
asap@2.0.6: {}
@@ -3935,10 +4113,14 @@ snapshots:
balanced-match@4.0.4: {}
base64-js@1.5.1: {}
better-result@2.10.0: {}
birpc@2.3.0: {}
bluebird@3.4.7: {}
body-parser@2.3.0:
dependencies:
bytes: 3.1.2
@@ -4082,6 +4264,8 @@ snapshots:
dependencies:
is-what: 5.5.0
core-util-is@1.0.3: {}
cors@2.8.6:
dependencies:
object-assign: 4.1.1
@@ -4148,6 +4332,8 @@ snapshots:
dependencies:
d3-array: 3.2.4
dayjs@1.11.21: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -4158,6 +4344,8 @@ snapshots:
deepmerge-ts@7.1.5: {}
deepmerge@4.3.1: {}
defu@6.1.7: {}
delaunator@5.1.0:
@@ -4177,8 +4365,54 @@ snapshots:
asap: 2.0.6
wrappy: 1.0.2
dingbat-to-unicode@1.0.1: {}
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.2.0
dom-serializer@3.1.1:
dependencies:
domelementtype: 3.0.0
domhandler: 6.0.1
entities: 8.0.0
domelementtype@2.3.0: {}
domelementtype@3.0.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domhandler@6.0.1:
dependencies:
domelementtype: 3.0.0
dompurify@3.4.13:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
domutils@4.0.2:
dependencies:
dom-serializer: 3.1.1
domelementtype: 3.0.0
domhandler: 6.0.1
dotenv@17.4.2: {}
duck@0.1.12:
dependencies:
underscore: 1.13.8
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -4204,8 +4438,12 @@ snapshots:
encodeurl@2.0.0: {}
entities@4.2.0: {}
entities@7.0.1: {}
entities@8.0.0: {}
env-paths@3.0.0: {}
es-define-property@1.0.1: {}
@@ -4538,6 +4776,20 @@ snapshots:
hookable@5.5.3: {}
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
htmlparser2@12.0.0:
dependencies:
domelementtype: 3.0.0
domhandler: 6.0.1
domutils: 4.0.2
entities: 8.0.0
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -4560,6 +4812,8 @@ snapshots:
ignore@7.0.6: {}
immediate@3.0.6: {}
import-fresh@3.2.1:
dependencies:
parent-module: 1.0.1
@@ -4581,12 +4835,16 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-plain-object@5.0.0: {}
is-promise@4.0.0: {}
is-property@1.0.2: {}
is-what@5.5.0: {}
isarray@1.0.0: {}
isexe@2.0.0: {}
iterare@1.2.1: {}
@@ -4613,10 +4871,21 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.2
readable-stream: 2.3.8
setimmediate: 1.0.5
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
launder@1.7.1:
dependencies:
dayjs: 1.11.21
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -4624,6 +4893,10 @@ snapshots:
libphonenumber-js@1.13.10: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
load-esm@1.0.3: {}
locate-path@6.0.0:
@@ -4636,6 +4909,12 @@ snapshots:
long@5.3.2: {}
lop@0.4.2:
dependencies:
duck: 0.1.12
option: 0.2.4
underscore: 1.13.8
loupe@3.2.1: {}
lru-cache@10.2.0: {}
@@ -4654,6 +4933,19 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mammoth@1.12.1:
dependencies:
'@xmldom/xmldom': 0.8.13
argparse: 1.0.3
base64-js: 1.5.1
bluebird: 3.4.7
dingbat-to-unicode: 1.0.1
jszip: 3.10.1
lop: 0.4.2
path-is-absolute: 1.0.1
underscore: 1.13.8
xmlbuilder: 10.1.1
mariadb@3.4.5:
dependencies:
'@types/geojson': 7946.0.16
@@ -4767,6 +5059,8 @@ snapshots:
dependencies:
wrappy: 1.0.2
option@0.2.4: {}
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -4788,16 +5082,22 @@ snapshots:
package-json-from-dist@1.0.1: {}
pako@1.0.2: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
parse-srcset@1.0.2: {}
parseurl@1.3.3: {}
path-browserify@1.0.1: {}
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
path-scurry@1.11.1:
@@ -4861,6 +5161,8 @@ snapshots:
- react
- react-dom
process-nextick-args@2.0.1: {}
proper-lockfile@4.1.2:
dependencies:
graceful-fs: 4.2.11
@@ -4902,6 +5204,16 @@ snapshots:
react@19.2.8: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
@@ -4976,6 +5288,8 @@ snapshots:
dependencies:
tslib: 2.8.1
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
safe-regex2@5.1.1:
@@ -4984,6 +5298,16 @@ snapshots:
safer-buffer@2.1.2: {}
sanitize-html@2.17.6:
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 12.0.0
is-plain-object: 5.0.0
launder: 1.7.1
parse-srcset: 1.0.2
postcss: 8.5.26
scheduler@0.27.0: {}
semver@7.8.5: {}
@@ -5015,6 +5339,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
setimmediate@1.0.5: {}
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
@@ -5061,6 +5387,8 @@ snapshots:
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}
sqlstring@2.3.3: {}
stackback@0.0.2: {}
@@ -5085,6 +5413,10 @@ snapshots:
emoji-regex: 9.2.2
strip-ansi: 7.2.0
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
@@ -5212,6 +5544,8 @@ snapshots:
uint8array-extras@1.5.0: {}
underscore@1.13.8: {}
undici-types@7.18.2: {}
unpipe@1.0.0: {}
@@ -5354,6 +5688,8 @@ snapshots:
wrappy@1.0.2: {}
xmlbuilder@10.1.1: {}
zeptomatch@2.1.0:
dependencies:
grammex: 3.1.13
+1
View File
@@ -4,6 +4,7 @@ packages:
overrides:
js-yaml: 5.2.2
lodash: 4.18.1
allowBuilds:
'@nestjs/core': true